├── src ├── assets │ └── .gitkeep ├── app │ ├── test-page-a │ │ ├── test-page-a.component.css │ │ ├── test-page-a.component.html │ │ ├── test-page-a.component.ts │ │ └── test-page-a.component.spec.ts │ ├── test-page-b │ │ ├── test-page-b.component.css │ │ ├── test-page-b.component.html │ │ ├── test-page-b.component.ts │ │ └── test-page-b.component.spec.ts │ ├── app.routes.ts │ ├── app.component.css │ ├── app.component.ts │ ├── app.providers.ts │ ├── app.component.spec.ts │ └── app.component.html ├── favicon.ico ├── styles.css ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── tsconfig.spec.json ├── tsconfig.app.json ├── main.ts ├── index.html └── jest.config.js ├── .eslintignore ├── projects └── ngx-google-analytics │ ├── src │ ├── lib │ │ ├── types │ │ │ ├── primitive.type.ts │ │ │ ├── data-layer.type.ts │ │ │ ├── gtag.type.ts │ │ │ └── ga-action.type.ts │ │ ├── tokens │ │ │ ├── ngx-google-analytics-window.ts │ │ │ ├── ngx-google-analytics-settings-token.ts │ │ │ ├── ngx-google-analytics-router-settings-token.ts │ │ │ ├── ngx-window-token.ts │ │ │ ├── ngx-data-layer-token.ts │ │ │ └── ngx-gtag-token.ts │ │ ├── interfaces │ │ │ ├── i-google-analytics-command.ts │ │ │ ├── i-google-analytics-sevice.ts │ │ │ ├── i-google-analytics-settings.ts │ │ │ └── i-google-analytics-routing-settings.ts │ │ ├── directives │ │ │ ├── ga-event-category.directive.spec.ts │ │ │ ├── ga-event-category.directive.ts │ │ │ ├── ga-event-form-input.directive.ts │ │ │ ├── ga-event-form-input.directive.spec.ts │ │ │ ├── ga-event.directive.ts │ │ │ └── ga-event.directive.spec.ts │ │ ├── ngx-google-analytics.module.ts │ │ ├── enums │ │ │ └── ga-action.enum.ts │ │ ├── ngx-google-analytics-router │ │ │ └── ngx-google-analytics-router.provider.ts │ │ ├── ngx-google-analytics.provider.ts │ │ ├── initializers │ │ │ ├── google-analytics.initializer.ts │ │ │ ├── google-analytics-router.initializer.ts │ │ │ └── google-analytics-router.initializer.spec.ts │ │ └── services │ │ │ ├── google-analytics.service.ts │ │ │ └── google-analytics.service.spec.ts │ ├── index.ts │ └── public_api.ts │ ├── ng-package.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ ├── jest.config.js │ ├── package.json │ └── tsconfig.lib.json ├── .vscode └── settings.json ├── e2e ├── src │ ├── app.po.ts │ └── app.e2e-spec.ts ├── tsconfig.e2e.json └── protractor.conf.js ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ ├── tests.yml │ └── publish.yml ├── .eslintrc.json ├── package.json ├── CHANGELOG.md ├── angular.json └── README.md /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /src/app/test-page-a/test-page-a.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/test-page-b/test-page-b.component.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hakimio/ngx-google-analytics/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/types/primitive.type.ts: -------------------------------------------------------------------------------- 1 | export type Primitive = string | number | boolean | Date; 2 | -------------------------------------------------------------------------------- /src/app/test-page-a/test-page-a.component.html: -------------------------------------------------------------------------------- 1 |

2 | test-page-a works! 3 |

4 | 5 | 6 | -------------------------------------------------------------------------------- /src/app/test-page-b/test-page-b.component.html: -------------------------------------------------------------------------------- 1 |

2 | test-page-b works! 3 |

4 | 5 | 6 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | // noinspection JSUnusedGlobalSymbols 2 | 3 | export const environment = { 4 | production: true, 5 | ga: 'ga4-tag-id' 6 | }; 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tslint.autoFixOnSave": true, 3 | "editor.detectIndentation": false, 4 | "editor.tabSize": 2, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.tslint": true 7 | } 8 | } -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/types/data-layer.type.ts: -------------------------------------------------------------------------------- 1 | import {GtagFnArgs} from './gtag.type'; 2 | 3 | /** 4 | * Provides an interface on a GA command list. 5 | */ 6 | export type DataLayer = GtagFnArgs[]; 7 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ngx-google-analytics", 4 | "lib": { 5 | "entryFile": "src/public_api.ts" 6 | } 7 | } -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Fix the annoying problem when you try to use this library on the same environment and VSCode always used public_api path to import files. 3 | */ 4 | export * from './public_api'; 5 | 6 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "compilationMode": "partial" 8 | } 9 | } -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": ["jest"] 6 | }, 7 | "include": [ 8 | "src/**/*.spec.ts", 9 | "src/**/*.d.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": [ 8 | "main.ts" 9 | ], 10 | "include": [ 11 | "src/**/*.d.ts" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getParagraphText() { 9 | return element(by.css('app-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/tokens/ngx-google-analytics-window.ts: -------------------------------------------------------------------------------- 1 | import {GtagFn} from '../types/gtag.type'; 2 | import {DataLayer} from '../types/data-layer.type'; 3 | 4 | export type GaWindow = Window & { 5 | gtag?: GtagFn; 6 | dataLayer?: DataLayer; 7 | }; 8 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": ["jest"] 6 | }, 7 | "include": [ 8 | "**/*.spec.ts", 9 | "**/*.d.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } -------------------------------------------------------------------------------- /projects/ngx-google-analytics/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.ts$': ['ts-jest', { 4 | diagnostics: false 5 | }] 6 | }, 7 | modulePathIgnorePatterns: [ 8 | "/dist/" 9 | ], 10 | verbose: true 11 | }; 12 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import {AppComponent} from './app/app.component'; 2 | import {bootstrapApplication} from '@angular/platform-browser'; 3 | import {APP_PROVIDERS} from './app/app.providers'; 4 | 5 | bootstrapApplication(AppComponent, { 6 | providers: APP_PROVIDERS 7 | }).catch(err => console.error(err)); 8 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/types/gtag.type.ts: -------------------------------------------------------------------------------- 1 | import {Primitive} from './primitive.type'; 2 | 3 | 4 | export type GtagFnArgs = (Primitive | { [param: string]: Primitive })[]; 5 | /** 6 | * Google Analytics GTagFn call signature 7 | */ 8 | export type GtagFn = (...args: GtagFnArgs) => void; 9 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/interfaces/i-google-analytics-command.ts: -------------------------------------------------------------------------------- 1 | import {GtagFnArgs} from '../types/gtag.type'; 2 | 3 | /** 4 | * Standardizes a common command protocol :) 5 | */ 6 | export interface IGoogleAnalyticsCommand { 7 | command: string; 8 | values: GtagFnArgs; 9 | } 10 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/directives/ga-event-category.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import {GaEventCategoryDirective} from './ga-event-category.directive'; 2 | 3 | describe('GaEventCategoryDirective', () => { 4 | it('should create an instance', () => { 5 | const directive = new GaEventCategoryDirective(); 6 | expect(directive).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.json] 12 | indent_size = 2 13 | insert_final_newline = false 14 | 15 | [*.md] 16 | max_line_length = off 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Ngx Google Analytics 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | 3 | describe('workspace-project App', () => { 4 | let page: AppPage; 5 | 6 | beforeEach(() => { 7 | page = new AppPage(); 8 | }); 9 | 10 | it('should display welcome message', () => { 11 | page.navigateTo(); 12 | expect(page.getParagraphText()).toEqual('Welcome to ngx-google-analytics-sdk!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/directives/ga-event-category.directive.ts: -------------------------------------------------------------------------------- 1 | import {Directive, Input} from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: ` 5 | [gaEvent][gaCategory], 6 | [gaCategory] 7 | `, 8 | exportAs: 'gaCategory', 9 | standalone: true 10 | }) 11 | export class GaEventCategoryDirective { 12 | 13 | @Input() gaCategory!: string; 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.ts$': ['ts-jest', { 4 | diagnostics: false 5 | }] 6 | }, 7 | modulePathIgnorePatterns: [ 8 | "/projects/" 9 | ], 10 | moduleNameMapper: { 11 | '@hakimio/ngx-google-analytics': "/dist/ngx-google-analytics/fesm2022/hakimio-ngx-google-analytics.mjs", 12 | }, 13 | verbose: true 14 | }; 15 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import {Routes} from '@angular/router'; 2 | import {TestPageAComponent} from './test-page-a/test-page-a.component'; 3 | import {TestPageBComponent} from './test-page-b/test-page-b.component'; 4 | 5 | export const ROUTES: Routes = [ 6 | { 7 | path: 'page-1', 8 | component: TestPageAComponent 9 | }, 10 | { 11 | path: 'page-2', 12 | component: TestPageBComponent 13 | } 14 | ]; 15 | -------------------------------------------------------------------------------- /src/app/test-page-a/test-page-a.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component} from '@angular/core'; 2 | import {RouterLink} from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'ga-app-test-page-a', 6 | templateUrl: './test-page-a.component.html', 7 | styleUrls: ['./test-page-a.component.css'], 8 | standalone: true, 9 | imports: [RouterLink], 10 | changeDetection: ChangeDetectionStrategy.OnPush 11 | }) 12 | export class TestPageAComponent {} 13 | -------------------------------------------------------------------------------- /src/app/test-page-b/test-page-b.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component} from '@angular/core'; 2 | import {RouterLink} from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'ga-app-test-page-b', 6 | templateUrl: './test-page-b.component.html', 7 | styleUrls: ['./test-page-b.component.css'], 8 | standalone: true, 9 | imports: [RouterLink], 10 | changeDetection: ChangeDetectionStrategy.OnPush 11 | }) 12 | export class TestPageBComponent {} 13 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/tokens/ngx-google-analytics-settings-token.ts: -------------------------------------------------------------------------------- 1 | import {InjectionToken} from '@angular/core'; 2 | import {IGoogleAnalyticsSettings} from '../interfaces/i-google-analytics-settings'; 3 | 4 | /** 5 | * Provide an Injection Token for global settings. 6 | */ 7 | export const NGX_GOOGLE_ANALYTICS_SETTINGS_TOKEN = new InjectionToken('ngx-google-analytics-settings', { 8 | factory: () => ({ga4TagId: '', enableTracing: false}) 9 | }); 10 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/tokens/ngx-google-analytics-router-settings-token.ts: -------------------------------------------------------------------------------- 1 | import {InjectionToken} from '@angular/core'; 2 | import {IGoogleAnalyticsRoutingSettings} from '../interfaces/i-google-analytics-routing-settings'; 3 | 4 | /** 5 | * Provide an Injection Token for global settings. 6 | */ 7 | export const NGX_GOOGLE_ANALYTICS_ROUTING_SETTINGS_TOKEN = new InjectionToken('ngx-google-analytics-routing-settings', { 8 | factory: () => ({}) 9 | }); 10 | -------------------------------------------------------------------------------- /src/app/app.component.css: -------------------------------------------------------------------------------- 1 | ul { 2 | list-style: none; 3 | padding: 0; 4 | } 5 | 6 | .horizontal-container { 7 | display: flex; 8 | gap: 0.5rem; 9 | } 10 | 11 | .vertical-container { 12 | display: flex; 13 | flex-direction: column; 14 | gap: 0.5rem; 15 | align-items: center; 16 | } 17 | 18 | .main-container { 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | } 23 | 24 | hr { 25 | width: 100%; 26 | height: 2px; 27 | background-color: #e0e0e0; 28 | border: none; 29 | } 30 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/ngx-google-analytics.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {GaEventCategoryDirective} from './directives/ga-event-category.directive'; 3 | import {GaEventFormInputDirective} from './directives/ga-event-form-input.directive'; 4 | import {GaEventDirective} from './directives/ga-event.directive'; 5 | 6 | const COMPONENTS = [GaEventDirective, GaEventCategoryDirective, GaEventFormInputDirective]; 7 | 8 | @NgModule({ 9 | imports: COMPONENTS, 10 | exports: COMPONENTS 11 | }) 12 | export class NgxGoogleAnalyticsModule {} 13 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component} from '@angular/core'; 2 | import {NgxGoogleAnalyticsModule} from '@hakimio/ngx-google-analytics'; 3 | import {RouterLink, RouterOutlet} from '@angular/router'; 4 | 5 | @Component({ 6 | selector: 'ga-app-root', 7 | templateUrl: './app.component.html', 8 | styleUrls: ['./app.component.css'], 9 | standalone: true, 10 | imports: [RouterLink, RouterOutlet, NgxGoogleAnalyticsModule], 11 | changeDetection: ChangeDetectionStrategy.OnPush 12 | }) 13 | export class AppComponent { 14 | title = 'ngx-google-analytics'; 15 | } 16 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/tokens/ngx-window-token.ts: -------------------------------------------------------------------------------- 1 | import {inject, InjectionToken} from '@angular/core'; 2 | import {DOCUMENT} from '@angular/common'; 3 | import {GaWindow} from './ngx-google-analytics-window'; 4 | 5 | /** 6 | * Provide DOM Window reference. 7 | */ 8 | export const NGX_WINDOW = new InjectionToken('ngx-window', { 9 | providedIn: 'root', 10 | factory: () => { 11 | const {defaultView} = inject(DOCUMENT); 12 | 13 | if (!defaultView) { 14 | throw new Error('Window is not available'); 15 | } 16 | 17 | return defaultView; 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.angular/cache 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | yarn-error.log 35 | testem.log 36 | /typings 37 | 38 | # System Files 39 | .DS_Store 40 | Thumbs.db 41 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hakimio/ngx-google-analytics", 3 | "version": "15.0.0", 4 | "description": "A simple Google Analytics wrapper for Angular apps", 5 | "keywords": [ "google", "google analytics", "angular", "ga4", "analytics 4" ], 6 | "homepage": "https://github.com/hakimio/ngx-google-analytics", 7 | "license": "MIT", 8 | "dependencies": { 9 | "tslib": "^2.4.0" 10 | }, 11 | "peerDependencies": { 12 | "@angular/common": ">=16.0.0", 13 | "@angular/core": ">=16.0.0", 14 | "@angular/router": ">=16.0.0", 15 | "rxjs": "^7.4.0" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/hakimio/ngx-google-analytics" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | ga: 'G-1E1TREZTG7' 8 | }; 9 | 10 | /* 11 | * For easier debugging in development mode, you can import the following file 12 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 13 | * 14 | * This import should be commented out in production mode because it will have a negative impact 15 | * on performance if an error is thrown. 16 | */ 17 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 18 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/directives/ga-event-form-input.directive.ts: -------------------------------------------------------------------------------- 1 | import {Directive, inject, Input} from '@angular/core'; 2 | import {GaEventDirective} from './ga-event.directive'; 3 | 4 | @Directive({ 5 | selector: ` 6 | input[gaEvent], 7 | select[gaEvent], 8 | textarea[gaEvent] 9 | `, 10 | standalone: true 11 | }) 12 | export class GaEventFormInputDirective { 13 | 14 | private readonly gaEvent = inject(GaEventDirective, { 15 | optional: true, 16 | host: true 17 | }); 18 | 19 | constructor() { 20 | this.gaBind = 'focus'; 21 | } 22 | 23 | @Input() set gaBind(bind: string) { 24 | if (this.gaEvent) { 25 | this.gaEvent.gaBind = bind; 26 | } 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/interfaces/i-google-analytics-sevice.ts: -------------------------------------------------------------------------------- 1 | import {Primitive} from '../types/primitive.type'; 2 | 3 | export interface IGoogleAnalyticsServiceEvent { 4 | category?: string; 5 | label?: string; 6 | // A value to measure something 7 | value?: number; 8 | // If user interaction is performed 9 | interaction?: boolean; 10 | // Custom dimensions 11 | options?: Record; 12 | } 13 | 14 | export interface IGoogleAnalyticsServicePageView { 15 | title?: string; 16 | location?: string; 17 | // Custom dimensions 18 | options?: Record; 19 | } 20 | 21 | export interface IGoogleAnalyticsServiceAppView { 22 | appId?: string; 23 | appVersion?: string; 24 | installerId?: string; 25 | } 26 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "declarationMap": true, 6 | "module": "ES2022", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "sourceMap": true, 10 | "inlineSources": true, 11 | "experimentalDecorators": true, 12 | "importHelpers": true, 13 | "types": [], 14 | "lib": [ 15 | "dom", 16 | "ES2022" 17 | ] 18 | }, 19 | "angularCompilerOptions": { 20 | "skipTemplateCodegen": true, 21 | "strictMetadataEmit": true, 22 | "fullTemplateTypeCheck": true, 23 | "strictInjectionParameters": true, 24 | "enableResourceInlining": true 25 | }, 26 | "exclude": [ 27 | "src/test.ts", 28 | "**/*.spec.ts" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/types/ga-action.type.ts: -------------------------------------------------------------------------------- 1 | // noinspection JSUnusedGlobalSymbols 2 | 3 | /** 4 | * A string that represents default GA action used by Google to generate e-commerce intelligence. 5 | * 6 | * You can provide a custom string as well. 7 | * @deprecated use lib/enums/ga-action.enum.ts instead 8 | */ 9 | export type GaAction = 'view_search_results' 10 | | 'add_payment_info' 11 | | 'add_to_cart' 12 | | 'add_to_wishlist' 13 | | 'begin_checkout' 14 | | 'checkout_progress' 15 | | 'generate_lead' 16 | | 'login' 17 | | 'purchase' 18 | | 'refund' 19 | | 'remove_from_cart' 20 | | 'search' 21 | | 'select_content' 22 | | 'set_checkout_option' 23 | | 'share' 24 | | 'sign_up' 25 | | 'view_item' 26 | | 'view_item_list' 27 | | 'view_promotion'; 28 | 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "downlevelIteration": true, 6 | "importHelpers": true, 7 | "outDir": "./dist/out-tsc", 8 | "sourceMap": true, 9 | "declaration": false, 10 | "module": "ES2022", 11 | "moduleResolution": "node", 12 | "experimentalDecorators": true, 13 | "target": "ES2022", 14 | "esModuleInterop": true, 15 | "typeRoots": [ 16 | "node_modules/@types" 17 | ], 18 | "lib": [ 19 | "ES2022", 20 | "dom" 21 | ], 22 | "paths": { 23 | "@hakimio/ngx-google-analytics": [ 24 | "dist/ngx-google-analytics" 25 | ], 26 | "@hakimio/ngx-google-analytics/*": [ 27 | "dist/ngx-google-analytics/*" 28 | ] 29 | }, 30 | "useDefineForClassFields": false 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/enums/ga-action.enum.ts: -------------------------------------------------------------------------------- 1 | // noinspection JSUnusedGlobalSymbols 2 | 3 | export enum GaActionEnum { 4 | ADD_PAYMENT_INFO = 'add_payment_info', 5 | ADD_TO_CART = 'add_to_cart', 6 | ADD_TO_WISHLIST = 'add_to_wishlist', 7 | BEGIN_CHECKOUT = 'begin_checkout', 8 | CHECKOUT_PROGRESS = 'checkout_progress', 9 | GENERATE_LEAD = 'generate_lead', 10 | LOGIN = 'login', 11 | PURCHASE = 'purchase', 12 | REFUND = 'refund', 13 | REMOVE_FROM_CART = 'remove_from_cart', 14 | SEARCH = 'search', 15 | SELECT_CONTENT = 'select_content', 16 | SET_CHECKOUT_OPTION = 'set_checkout_option', 17 | SHARE = 'share', 18 | SIGN_UP = 'sign_up', 19 | VIEW_ITEM = 'view_item', 20 | VIEW_ITEM_LIST = 'view_item_list', 21 | VIEW_PROMOTION = 'view_promotion', 22 | VIEW_SEARCH_RESULT = 'view_search_results' 23 | } 24 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/tokens/ngx-data-layer-token.ts: -------------------------------------------------------------------------------- 1 | import {inject, InjectionToken} from '@angular/core'; 2 | import {DataLayer} from '../types/data-layer.type'; 3 | import {GaWindow} from './ngx-google-analytics-window'; 4 | import {NGX_WINDOW} from './ngx-window-token'; 5 | 6 | /** 7 | * Check if there is some global function called gtag on Window object, or create an empty function that doesn't break code... 8 | */ 9 | export function getDataLayerFn(window: GaWindow): DataLayer { 10 | return (window) 11 | ? window['dataLayer'] = window['dataLayer'] || [] 12 | : null; 13 | } 14 | 15 | /** 16 | * Provides an injection token to access Google Analytics DataLayer Collection 17 | */ 18 | export const NGX_DATA_LAYER = new InjectionToken('ngx-data-layer', { 19 | providedIn: 'root', 20 | factory: () => getDataLayerFn(inject(NGX_WINDOW)) 21 | }); 22 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/ngx-google-analytics-router/ngx-google-analytics-router.provider.ts: -------------------------------------------------------------------------------- 1 | import {EnvironmentProviders, makeEnvironmentProviders} from '@angular/core'; 2 | import {NGX_GOOGLE_ANALYTICS_ROUTING_SETTINGS_TOKEN} from '../tokens/ngx-google-analytics-router-settings-token'; 3 | import {IGoogleAnalyticsRoutingSettings} from '../interfaces/i-google-analytics-routing-settings'; 4 | import {NGX_GOOGLE_ANALYTICS_ROUTER_INITIALIZER_PROVIDER} from '../initializers/google-analytics-router.initializer'; 5 | 6 | // noinspection JSUnusedGlobalSymbols 7 | export function provideGoogleAnalyticsRouter(settings?: IGoogleAnalyticsRoutingSettings): EnvironmentProviders { 8 | return makeEnvironmentProviders([ 9 | NGX_GOOGLE_ANALYTICS_ROUTER_INITIALIZER_PROVIDER, 10 | { 11 | provide: NGX_GOOGLE_ANALYTICS_ROUTING_SETTINGS_TOKEN, 12 | useValue: settings ?? {} 13 | } 14 | ]); 15 | } 16 | -------------------------------------------------------------------------------- /src/app/app.providers.ts: -------------------------------------------------------------------------------- 1 | import {EnvironmentProviders, Provider, provideZoneChangeDetection} from '@angular/core'; 2 | import {provideRouter} from '@angular/router'; 3 | import {ROUTES} from './app.routes'; 4 | import {provideAnimations} from '@angular/platform-browser/animations'; 5 | import {provideGoogleAnalytics, provideGoogleAnalyticsRouter} from '@hakimio/ngx-google-analytics'; 6 | import {environment} from '../environments/environment'; 7 | import {REMOVE_STYLES_ON_COMPONENT_DESTROY} from '@angular/platform-browser'; 8 | 9 | export const APP_PROVIDERS: (Provider | EnvironmentProviders)[] = [ 10 | { 11 | provide: REMOVE_STYLES_ON_COMPONENT_DESTROY, 12 | useValue: true 13 | }, 14 | provideZoneChangeDetection({eventCoalescing: true}), 15 | provideRouter(ROUTES), 16 | provideAnimations(), 17 | provideGoogleAnalytics(environment.ga), 18 | provideGoogleAnalyticsRouter({include: ['/page-*']}) 19 | ]; 20 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/ngx-google-analytics.provider.ts: -------------------------------------------------------------------------------- 1 | import {IGoogleAnalyticsOptions, IGoogleAnalyticsSettings} from './interfaces/i-google-analytics-settings'; 2 | import {EnvironmentProviders, makeEnvironmentProviders} from '@angular/core'; 3 | import {NGX_GOOGLE_ANALYTICS_SETTINGS_TOKEN} from './tokens/ngx-google-analytics-settings-token'; 4 | import {NGX_GOOGLE_ANALYTICS_INITIALIZER_PROVIDER} from './initializers/google-analytics.initializer'; 5 | 6 | export function provideGoogleAnalytics( 7 | ga4TagId: string, 8 | options?: IGoogleAnalyticsOptions 9 | ): EnvironmentProviders { 10 | return makeEnvironmentProviders([ 11 | { 12 | provide: NGX_GOOGLE_ANALYTICS_SETTINGS_TOKEN, 13 | useValue: { 14 | ga4TagId, 15 | ...options 16 | } as IGoogleAnalyticsSettings 17 | }, 18 | NGX_GOOGLE_ANALYTICS_INITIALIZER_PROVIDER 19 | ]); 20 | } 21 | -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | const {StacktraceOption} = require("jasmine-spec-reporter/built/configuration"); 6 | 7 | exports.config = { 8 | allScriptsTimeout: 11000, 9 | specs: [ 10 | './src/**/*.e2e-spec.ts' 11 | ], 12 | capabilities: { 13 | 'browserName': 'chrome' 14 | }, 15 | directConnect: true, 16 | baseUrl: 'http://localhost:4200/', 17 | framework: 'jasmine', 18 | jasmineNodeOpts: { 19 | showColors: true, 20 | defaultTimeoutInterval: 30000, 21 | print: function() {} 22 | }, 23 | onPrepare() { 24 | require('ts-node').register({ 25 | project: require('path').join(__dirname, './tsconfig.e2e.json') 26 | }); 27 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: StacktraceOption.PRETTY } })); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/app/test-page-a/test-page-a.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFixture, TestBed} from '@angular/core/testing'; 2 | 3 | import {TestPageAComponent} from './test-page-a.component'; 4 | import {RouterTestingModule} from '@angular/router/testing'; 5 | 6 | describe('TestPageAComponent', () => { 7 | let component: TestPageAComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async () => { 11 | await TestBed.configureTestingModule({ 12 | imports: [ 13 | RouterTestingModule, 14 | TestPageAComponent 15 | ] 16 | }) 17 | .compileComponents(); 18 | }); 19 | 20 | beforeEach(() => { 21 | fixture = TestBed.createComponent(TestPageAComponent); 22 | component = fixture.componentInstance; 23 | fixture.detectChanges(); 24 | }); 25 | 26 | it('should create', () => { 27 | expect(component).toBeTruthy(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/app/test-page-b/test-page-b.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentFixture, TestBed} from '@angular/core/testing'; 2 | 3 | import {TestPageBComponent} from './test-page-b.component'; 4 | import {RouterTestingModule} from '@angular/router/testing'; 5 | 6 | describe('TestPageBComponent', () => { 7 | let component: TestPageBComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async () => { 11 | await TestBed.configureTestingModule({ 12 | imports: [ 13 | RouterTestingModule, 14 | TestPageBComponent 15 | ] 16 | }) 17 | .compileComponents(); 18 | }); 19 | 20 | beforeEach(() => { 21 | fixture = TestBed.createComponent(TestPageBComponent); 22 | component = fixture.componentInstance; 23 | fixture.detectChanges(); 24 | }); 25 | 26 | it('should create', () => { 27 | expect(component).toBeTruthy(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Max 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/interfaces/i-google-analytics-settings.ts: -------------------------------------------------------------------------------- 1 | import {IGoogleAnalyticsCommand} from './i-google-analytics-command'; 2 | 3 | /** 4 | * Standardize a key-value object to configure GA installation. 5 | */ 6 | export interface IGoogleAnalyticsSettings { 7 | /** GA4 Tag Id ("G-XXXXXX") */ 8 | ga4TagId: string; 9 | /** 10 | * You can inject custom initialization commands like UserId or other e-commerce features. 11 | * If set, it will run any GA Commands in sequence after setting up GA environment. 12 | */ 13 | initCommands?: Array; 14 | /** 15 | * Instead of the default "https://www.googletagmanager.com/gtag/js" script, you can also use tag manager script: 16 | * "https://www.googletagmanager.com/gtm.js". If you use "gtm.js", remember to also use tag manager ID: 17 | * "GTM-XXXXXX" instead of gtag id "G-XXXXXX". 18 | */ 19 | uri?: string; 20 | /** If true, trace GA tracking errors in production mode */ 21 | enableTracing?: boolean; 22 | /** If set, nonce will be added to script tag **/ 23 | nonce?: string; 24 | } 25 | 26 | export type IGoogleAnalyticsOptions = Exclude; 27 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/interfaces/i-google-analytics-routing-settings.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Provide some custom settings for Automatic Router listener behaviour. 3 | */ 4 | export interface IGoogleAnalyticsRoutingSettings { 5 | /** 6 | * Exclude the given path to the auto page-view trigger. 7 | * 8 | * ```ts 9 | * @NgModule({ 10 | * imports: [ 11 | * NgxGoogleAnalyticsModule 12 | * ], 13 | * providers: [ 14 | * provideGoogleAnalytics(...), 15 | * provideGoogleAnalyticsRouter({ exclude: ['/login', '/internal/*', /regExp/gi] }) 16 | * ] 17 | * }) 18 | * AppModule 19 | * ``` 20 | */ 21 | exclude?: Array; 22 | 23 | /** 24 | * Auto trigger page-view only for allowed uris. 25 | * 26 | * ```ts 27 | * @NgModule({ 28 | * imports: [ 29 | * NgxGoogleAnalyticsModule 30 | * ], 31 | * providers: [ 32 | * provideGoogleAnalytics(...), 33 | * provideGoogleAnalyticsRouter({ include: ['/login', '/internal/*', /regExp/gi] }) 34 | * ] 35 | * }) 36 | * AppModule 37 | * ``` 38 | */ 39 | include?: Array; 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Build and Tests 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the master branch 7 | on: 8 | push: 9 | branches: 10 | - master 11 | - releases/* 12 | pull_request: 13 | branches: 14 | - master 15 | - releases/* 16 | 17 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 18 | jobs: 19 | run-tests: 20 | # The type of runner that the job will run on 21 | if: "!contains(github.event.head_commit.message, 'skip ci')" 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 26 | - uses: actions/checkout@v3 27 | - name: Set Node.js 18.x 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: 18.x 31 | 32 | - name: Run install 33 | uses: borales/actions-yarn@v4 34 | with: 35 | cmd: install 36 | 37 | - name: Build Angular Library 38 | run: npm run build 39 | 40 | - name: Lint 41 | run: npm run lint 42 | 43 | - name: Run Tests 44 | run: npm run test 45 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | import {AppComponent} from './app.component'; 3 | import {RouterTestingModule} from '@angular/router/testing'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(async () => { 7 | await TestBed.configureTestingModule({ 8 | imports: [ 9 | RouterTestingModule, 10 | AppComponent 11 | ] 12 | }).compileComponents(); 13 | }); 14 | 15 | it('should create the app', () => { 16 | const fixture = TestBed.createComponent(AppComponent); 17 | const app = fixture.debugElement.componentInstance; 18 | expect(app).toBeTruthy(); 19 | }); 20 | 21 | it(`should have as title 'ngx-google-analytics'`, () => { 22 | const fixture = TestBed.createComponent(AppComponent); 23 | const app = fixture.debugElement.componentInstance; 24 | expect(app.title).toEqual('ngx-google-analytics'); 25 | }); 26 | 27 | it('should render title in a h1 tag', () => { 28 | const fixture = TestBed.createComponent(AppComponent); 29 | fixture.detectChanges(); 30 | const compiled = fixture.debugElement.nativeElement; 31 | expect(compiled.querySelector('h1').textContent).toContain('Welcome to ngx-google-analytics!'); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/tokens/ngx-gtag-token.ts: -------------------------------------------------------------------------------- 1 | import {inject, InjectionToken} from '@angular/core'; 2 | import {DataLayer} from '../types/data-layer.type'; 3 | import {GtagFn, GtagFnArgs} from '../types/gtag.type'; 4 | import {NGX_DATA_LAYER} from './ngx-data-layer-token'; 5 | import {GaWindow} from './ngx-google-analytics-window'; 6 | import {NGX_WINDOW} from './ngx-window-token'; 7 | 8 | /** 9 | * Check if there is some global function called gtag on Window object, or create an empty function that doesn't break code... 10 | */ 11 | export function getGtagFn(window: GaWindow, dataLayer: DataLayer): GtagFn | null { 12 | return (window) 13 | ? window['gtag'] = window['gtag'] || function () { 14 | // IMPORTANT: rest param syntax (...args) cannot be used here since "gtag" push implementation requires 15 | // "callee" information which is not available in normal array 16 | // eslint-disable-next-line prefer-rest-params 17 | dataLayer.push(arguments as unknown as GtagFnArgs); 18 | } 19 | : null; 20 | } 21 | 22 | /** 23 | * Provides an injection token to access Google Analytics Gtag Function 24 | */ 25 | export const NGX_GTAG_FN = new InjectionToken('ngx-gtag-fn', { 26 | providedIn: 'root', 27 | factory: () => getGtagFn(inject(NGX_WINDOW), inject(NGX_DATA_LAYER)) 28 | }); 29 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/public_api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of ngx-google-analytics 3 | */ 4 | 5 | export * from './lib/directives/ga-event-category.directive'; 6 | export * from './lib/directives/ga-event.directive'; 7 | export * from './lib/directives/ga-event-form-input.directive'; 8 | 9 | export * from './lib/enums/ga-action.enum'; 10 | 11 | export * from './lib/initializers/google-analytics.initializer'; 12 | export * from './lib/initializers/google-analytics-router.initializer'; 13 | 14 | export * from './lib/interfaces/i-google-analytics-command'; 15 | export * from './lib/interfaces/i-google-analytics-routing-settings'; 16 | export * from './lib/interfaces/i-google-analytics-settings'; 17 | 18 | export * from './lib/services/google-analytics.service'; 19 | 20 | export * from './lib/tokens/ngx-data-layer-token'; 21 | export * from './lib/tokens/ngx-google-analytics-router-settings-token'; 22 | export * from './lib/tokens/ngx-google-analytics-settings-token'; 23 | export * from './lib/tokens/ngx-gtag-token'; 24 | export * from './lib/tokens/ngx-window-token'; 25 | 26 | export * from './lib/types/data-layer.type'; 27 | export * from './lib/types/ga-action.type'; 28 | export * from './lib/types/gtag.type'; 29 | export * from './lib/types/primitive.type'; 30 | 31 | export * from './lib/ngx-google-analytics.module'; 32 | export * from './lib/ngx-google-analytics-router/ngx-google-analytics-router.provider'; 33 | export * from './lib/ngx-google-analytics.provider'; 34 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 | Welcome to {{ title }}! 5 |

6 | 7 | 17 | 18 | 19 | 20 |
21 |

Directive tests

22 | 23 |
24 |
25 | 26 | 27 | 28 |
29 | 30 |
31 | 32 |
33 |
34 | 35 |

Group Directive Test

36 | 37 |
38 | 39 | 40 | 41 |
42 |
43 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "overrides": [ 4 | { 5 | "files": [ 6 | "*.ts" 7 | ], 8 | "parserOptions": { 9 | "project": [ 10 | "tsconfig.json" 11 | ], 12 | "createDefaultProgram": true 13 | }, 14 | "extends": [ 15 | "eslint:recommended", 16 | "plugin:@angular-eslint/recommended", 17 | "plugin:@typescript-eslint/recommended", 18 | "plugin:@angular-eslint/template/process-inline-templates" 19 | ], 20 | "rules": { 21 | "@angular-eslint/component-selector": [ 22 | "error", 23 | { 24 | "prefix": "ga", 25 | "style": "kebab-case", 26 | "type": "element" 27 | } 28 | ], 29 | "@angular-eslint/directive-selector": [ 30 | "error", 31 | { 32 | "prefix": "ga", 33 | "style": "camelCase", 34 | "type": "attribute" 35 | } 36 | ], 37 | "one-var": "off", 38 | "no-trailing-spaces": "off", 39 | "@typescript-eslint/member-ordering": "off", 40 | "@typescript-eslint/semi": ["error"], 41 | "@typescript-eslint/member-delimiter-style": [ 42 | "error", 43 | { 44 | "multiline": { 45 | "delimiter": "semi", 46 | "requireLast": true 47 | }, 48 | "singleline": { 49 | "delimiter": "semi", 50 | "requireLast": false 51 | } 52 | } 53 | ], 54 | "@angular-eslint/prefer-standalone-component": [ 55 | "error" 56 | ], 57 | "@angular-eslint/prefer-on-push-component-change-detection": [ 58 | "error" 59 | ] 60 | } 61 | }, 62 | { 63 | "files": [ 64 | "*.html" 65 | ], 66 | "extends": [ 67 | "plugin:@angular-eslint/template/recommended" 68 | ], 69 | "rules": {} 70 | } 71 | ] 72 | } 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-google-analytics-sdk", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build ngx-google-analytics", 8 | "build:watch": "ng build ngx-google-analytics --watch", 9 | "test": "ng test ngx-google-analytics", 10 | "lint": "ng lint ngx-google-analytics", 11 | "build:demo": "ng build ngx-google-analytics-sdk", 12 | "test:demo": "ng test ngx-google-analytics-sdk", 13 | "lint:demo": "ng lint ngx-google-analytics-sdk", 14 | "e2e": "ng e2e" 15 | }, 16 | "private": true, 17 | "dependencies": { 18 | "@angular/animations": "^16.2.6", 19 | "@angular/cli": "^16.2.3", 20 | "@angular/common": "^16.2.6", 21 | "@angular/compiler": "^16.2.6", 22 | "@angular/core": "^16.2.6", 23 | "@angular/forms": "^16.2.6", 24 | "@angular/platform-browser": "^16.2.6", 25 | "@angular/platform-browser-dynamic": "^16.2.6", 26 | "@angular/router": "^16.2.6", 27 | "rxjs": "^7.8.1", 28 | "tslib": "^2.6.2", 29 | "zone.js": "~0.13.3" 30 | }, 31 | "devDependencies": { 32 | "@angular-builders/jest": "^16.0.1", 33 | "@angular-devkit/build-angular": "^16.2.3", 34 | "@angular-devkit/core": "^16.2.3", 35 | "@angular-devkit/schematics": "^16.2.3", 36 | "@angular-eslint/builder": "^16.2.0", 37 | "@angular-eslint/eslint-plugin": "^16.2.0", 38 | "@angular-eslint/eslint-plugin-template": "^16.2.0", 39 | "@angular-eslint/schematics": "^16.2.0", 40 | "@angular-eslint/template-parser": "^16.2.0", 41 | "@angular/compiler-cli": "^16.2.6", 42 | "@angular/language-service": "^16.2.6", 43 | "@types/jest": "^29.5.5", 44 | "@types/node": "^18.18.0", 45 | "@typescript-eslint/eslint-plugin": "^6.7.3", 46 | "@typescript-eslint/parser": "^6.7.3", 47 | "eslint": "^8.50.0", 48 | "jest": "^29.7.0", 49 | "ng-packagr": "^16.2.3", 50 | "protractor": "^7.0.0", 51 | "ts-node": "^10.9.1", 52 | "typescript": "~5.1.6" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/directives/ga-event-form-input.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component} from '@angular/core'; 2 | import {ComponentFixture, TestBed} from '@angular/core/testing'; 3 | import {NgxGoogleAnalyticsModule} from '../ngx-google-analytics.module'; 4 | import {GoogleAnalyticsService} from '../services/google-analytics.service'; 5 | import {GaEventFormInputDirective} from './ga-event-form-input.directive'; 6 | import {GaEventDirective} from './ga-event.directive'; 7 | import {By} from '@angular/platform-browser'; 8 | 9 | describe('GaEventFormInputDirective', () => { 10 | 11 | @Component({ 12 | selector: 'ga-host', 13 | template: ``, 14 | standalone: true, 15 | changeDetection: ChangeDetectionStrategy.OnPush, 16 | imports: [ 17 | NgxGoogleAnalyticsModule 18 | ] 19 | }) 20 | class HostComponent {} 21 | 22 | let gaEventFormInput: GaEventFormInputDirective, 23 | gaEvent: GaEventDirective, 24 | fixture: ComponentFixture; 25 | 26 | beforeEach(async () => { 27 | await TestBed.configureTestingModule({ 28 | imports: [ 29 | HostComponent 30 | ] 31 | }).compileComponents(); 32 | 33 | fixture = TestBed.createComponent(HostComponent); 34 | fixture.detectChanges(); 35 | const debugEl = fixture.debugElement, 36 | inputElInjector = debugEl.query(By.css('input')).injector; 37 | 38 | gaEventFormInput = inputElInjector.get(GaEventFormInputDirective); 39 | gaEvent = inputElInjector.get(GaEventDirective); 40 | }); 41 | 42 | it('should create an instance', () => { 43 | expect(gaEventFormInput).toBeTruthy(); 44 | }); 45 | 46 | it('should update gaBind when input is updated', () => { 47 | gaEventFormInput.gaBind = 'click'; 48 | expect(gaEvent.gaBind).toBe('click'); 49 | }); 50 | 51 | it('should use `focus` as a default gaBind', () => { 52 | expect(gaEvent.gaBind).toBe('focus'); 53 | }); 54 | 55 | it('should call `GoogleAnalyticsService.event()` on trigger focus at input', () => { 56 | const ga = TestBed.inject(GoogleAnalyticsService), 57 | spyOnGa = jest.spyOn(ga, 'event'), 58 | input = fixture.debugElement.query(e => e.name === 'input'); 59 | 60 | fixture.detectChanges(); 61 | input.nativeElement.dispatchEvent(new FocusEvent('focus')); 62 | fixture.detectChanges(); 63 | 64 | expect(spyOnGa).toHaveBeenCalledWith('test', expect.any(Object)); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages 3 | 4 | name: Bump and Publish 5 | 6 | on: 7 | release: 8 | types: [released] 9 | # refs/tags/x.x.x 10 | 11 | jobs: 12 | tests: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set Node.js 18.x 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: 18.x 20 | 21 | - name: Run install 22 | uses: borales/actions-yarn@v4 23 | with: 24 | cmd: install 25 | - name: Test 26 | run: npm test 27 | 28 | bump-and-build: 29 | needs: tests 30 | runs-on: ubuntu-latest 31 | steps: 32 | - name: Get the version 33 | id: get_version 34 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 35 | - uses: actions/checkout@v3 36 | - name: Set Node.js 18.x 37 | uses: actions/setup-node@v3 38 | with: 39 | node-version: 18.x 40 | 41 | - name: Run install 42 | uses: borales/actions-yarn@v4 43 | with: 44 | cmd: install 45 | - run: npm --no-git-tag-version --allow-same-version version ${{ steps.get_version.outputs.VERSION }} 46 | working-directory: projects/ngx-google-analytics 47 | - run: npm run build 48 | - run: | 49 | cp LICENSE dist/ngx-google-analytics 50 | cp README.md dist/ngx-google-analytics 51 | cp CHANGELOG.md dist/ngx-google-analytics 52 | - run: npm pack 53 | working-directory: dist/ngx-google-analytics 54 | - name: 'Upload Build Artifact ${{ steps.get_version.outputs.VERSION }}' 55 | uses: actions/upload-artifact@v2 56 | with: 57 | name: ${{ steps.get_version.outputs.VERSION }}.tgz 58 | path: dist/ngx-google-analytics/hakimio-ngx-google-analytics-${{ steps.get_version.outputs.VERSION }}.tgz 59 | 60 | publish-npm: 61 | needs: bump-and-build 62 | runs-on: ubuntu-latest 63 | steps: 64 | - name: Get the version 65 | id: get_version 66 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 67 | - name: Download a Build Artifact 68 | uses: actions/download-artifact@v2 69 | with: 70 | name: ${{ steps.get_version.outputs.VERSION }}.tgz 71 | - uses: actions/setup-node@v1 72 | with: 73 | node-version: 18.x 74 | registry-url: https://registry.npmjs.org/ 75 | - run: npm publish --access public hakimio-ngx-google-analytics-${{ steps.get_version.outputs.VERSION }}.tgz 76 | env: 77 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 78 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/directives/ga-event.directive.ts: -------------------------------------------------------------------------------- 1 | import {Directive, ElementRef, inject, Input, isDevMode, OnDestroy} from '@angular/core'; 2 | import {fromEvent, Subscription} from 'rxjs'; 3 | import {GaActionEnum} from '../enums/ga-action.enum'; 4 | import {GoogleAnalyticsService} from '../services/google-analytics.service'; 5 | import {NGX_GOOGLE_ANALYTICS_SETTINGS_TOKEN} from '../tokens/ngx-google-analytics-settings-token'; 6 | import {GaEventCategoryDirective} from './ga-event-category.directive'; 7 | 8 | @Directive({ 9 | selector: `[gaEvent]`, 10 | exportAs: 'gaEvent', 11 | standalone: true 12 | }) 13 | export class GaEventDirective implements OnDestroy { 14 | 15 | @Input() gaAction!: GaActionEnum | string; 16 | @Input() gaLabel!: string; 17 | @Input() label!: string; 18 | @Input() gaValue!: number; 19 | @Input() gaInteraction!: boolean; 20 | @Input() gaEvent!: GaActionEnum | string; 21 | private bindSubscription?: Subscription; 22 | 23 | private readonly gaCategoryDirective = inject(GaEventCategoryDirective, {optional: true}); 24 | private readonly gaService = inject(GoogleAnalyticsService); 25 | private readonly settings = inject(NGX_GOOGLE_ANALYTICS_SETTINGS_TOKEN); 26 | private readonly el = inject(ElementRef); 27 | 28 | constructor() { 29 | this.gaBind = 'click'; 30 | } 31 | 32 | private _gaBind!: string; 33 | 34 | @Input() 35 | set gaBind(gaBind: string) { 36 | if (this.bindSubscription) { 37 | this.bindSubscription.unsubscribe(); 38 | } 39 | 40 | this._gaBind = gaBind; 41 | this.bindSubscription = fromEvent(this.el.nativeElement, gaBind).subscribe(() => this.trigger()); 42 | } 43 | get gaBind(): string { 44 | return this._gaBind; 45 | } 46 | 47 | ngOnDestroy() { 48 | if (this.bindSubscription) { 49 | this.bindSubscription.unsubscribe(); 50 | } 51 | } 52 | 53 | protected trigger() { 54 | try { 55 | if (!this.gaAction && !this.gaEvent) { 56 | throw new Error('You must provide a gaAction attribute to identify this event.'); 57 | } 58 | 59 | this.gaService 60 | .event( 61 | this.gaAction || this.gaEvent, 62 | { 63 | category: this.gaCategoryDirective?.gaCategory, 64 | label: this.gaLabel || this.label, 65 | value: this.gaValue, 66 | interaction: this.gaInteraction 67 | } 68 | ); 69 | } catch (err) { 70 | this.throw(err); 71 | } 72 | } 73 | 74 | protected throw(err: Error) { 75 | if ((isDevMode() || this.settings.enableTracing) && console && console.warn) { 76 | console.warn(err); 77 | } 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/initializers/google-analytics.initializer.ts: -------------------------------------------------------------------------------- 1 | import {DOCUMENT} from '@angular/common'; 2 | import {APP_INITIALIZER, isDevMode, Provider} from '@angular/core'; 3 | import {IGoogleAnalyticsSettings} from '../interfaces/i-google-analytics-settings'; 4 | import {NGX_GOOGLE_ANALYTICS_SETTINGS_TOKEN} from '../tokens/ngx-google-analytics-settings-token'; 5 | import {NGX_GTAG_FN} from '../tokens/ngx-gtag-token'; 6 | import {GtagFn} from '../types/gtag.type'; 7 | 8 | /** 9 | * Provide a DI Configuration to attach GA Initialization at Angular Startup Cycle. 10 | */ 11 | export const NGX_GOOGLE_ANALYTICS_INITIALIZER_PROVIDER: Provider = { 12 | provide: APP_INITIALIZER, 13 | multi: true, 14 | useFactory: GoogleAnalyticsInitializer, 15 | deps: [ 16 | NGX_GOOGLE_ANALYTICS_SETTINGS_TOKEN, 17 | NGX_GTAG_FN, 18 | DOCUMENT 19 | ] 20 | }; 21 | 22 | /** 23 | * Create a script element on DOM and link it to Google Analytics tracking code URI. 24 | * After that, execute exactly same init process as tracking snippet code. 25 | */ 26 | export function GoogleAnalyticsInitializer( 27 | settings: IGoogleAnalyticsSettings, 28 | gtag: GtagFn, 29 | document: Document 30 | ) { 31 | return async () => { 32 | if (!settings.ga4TagId) { 33 | if (!isDevMode()) { 34 | console.error('Empty tracking code for Google Analytics. Make sure to provide one when initializing NgxGoogleAnalyticsModule.'); 35 | } 36 | 37 | return; 38 | } 39 | 40 | if (!gtag) { 41 | if (!isDevMode()) { 42 | console.error('Couldn\'t create or read gtag() fn. Make sure this module is running on a Browser w/ access to Window interface.'); 43 | } 44 | 45 | return; 46 | } 47 | 48 | if (!document) { 49 | if (!isDevMode()) { 50 | console.error('Couldn\'t to access Document interface. Make sure this module is running on a Browser w/ access to Document interface.'); 51 | } 52 | } 53 | 54 | // Set default ga.js uri 55 | settings.uri = settings.uri || `https://www.googletagmanager.com/gtag/js?id=${settings.ga4TagId}`; 56 | 57 | // these commands should run first! 58 | settings.initCommands = settings?.initCommands ?? []; 59 | 60 | // assert config command 61 | if (!settings.initCommands.find(x => x.command === 'config')) { 62 | settings.initCommands.unshift({command: 'config', values: [settings.ga4TagId]}); 63 | } 64 | 65 | // assert js command 66 | if (!settings.initCommands.find(x => x.command === 'js')) { 67 | settings.initCommands.unshift({command: 'js', values: [new Date()]}); 68 | } 69 | 70 | for (const command of settings.initCommands) { 71 | gtag(command.command, ...command.values); 72 | } 73 | 74 | const s: HTMLScriptElement = document.createElement('script'); 75 | s.async = true; 76 | s.src = settings.uri; 77 | 78 | if (settings.nonce) { 79 | s.setAttribute('nonce', settings.nonce); 80 | } 81 | 82 | const head: HTMLHeadElement = document.getElementsByTagName('head')[0]; 83 | head.appendChild(s); 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/initializers/google-analytics-router.initializer.ts: -------------------------------------------------------------------------------- 1 | import {APP_BOOTSTRAP_LISTENER, ComponentRef, Provider} from '@angular/core'; 2 | import {Event, NavigationEnd, Router} from '@angular/router'; 3 | import {filter, skip} from 'rxjs'; 4 | import {IGoogleAnalyticsRoutingSettings} from '../interfaces/i-google-analytics-routing-settings'; 5 | import {GoogleAnalyticsService} from '../services/google-analytics.service'; 6 | import {NGX_GOOGLE_ANALYTICS_ROUTING_SETTINGS_TOKEN} from '../tokens/ngx-google-analytics-router-settings-token'; 7 | 8 | /** 9 | * Provide a DI Configuration to attach GA Trigger to Router Events at Angular Startup Cycle. 10 | */ 11 | export const NGX_GOOGLE_ANALYTICS_ROUTER_INITIALIZER_PROVIDER: Provider = { 12 | provide: APP_BOOTSTRAP_LISTENER, 13 | multi: true, 14 | useFactory: GoogleAnalyticsRouterInitializer, 15 | deps: [ 16 | NGX_GOOGLE_ANALYTICS_ROUTING_SETTINGS_TOKEN, 17 | GoogleAnalyticsService 18 | ] 19 | }; 20 | 21 | /** 22 | * Attach a listener to `NavigationEnd` Router event. So, every time Router finish the page resolution it should call `NavigationEnd` event. 23 | * We assume that NavigationEnd is the final page resolution and call GA `page_view` command. 24 | * 25 | * To avoid double binds, we also destroy the subscription when de Bootstrap Component is destroyed. But, we don't know for sure 26 | * that this strategy does not cause double bind on multiple bootstrap components. 27 | * 28 | * We are using the component's injector reference to resolve Router, so I hope there is no problem with double binding. 29 | * 30 | * If you have this problem, I encourage not Use NgxGoogleAnalyticsRouterModule and attach the listener on AppComponent initialization. 31 | */ 32 | export function GoogleAnalyticsRouterInitializer( 33 | settings: IGoogleAnalyticsRoutingSettings, 34 | gaService: GoogleAnalyticsService 35 | ) { 36 | return (c: ComponentRef) => { 37 | const router = c.injector.get(Router); 38 | const {include = [], exclude = []} = settings ?? {}; 39 | const includeRules = normalizePathRules(include); 40 | const excludeRules = normalizePathRules(exclude); 41 | const subs = router 42 | .events 43 | .pipe( 44 | filter((event: Event): event is NavigationEnd => event instanceof NavigationEnd), 45 | skip(1), // Prevent double views on the first trigger (because GA Already send one ping on setup) 46 | filter(event => includeRules.length > 0 47 | ? includeRules.some(rule => rule.test(event.urlAfterRedirects)) 48 | : true), 49 | filter(event => excludeRules.length > 0 50 | ? !excludeRules.some(rule => rule.test(event.urlAfterRedirects)) 51 | : true) 52 | ) 53 | .subscribe(event => gaService.pageView(event.urlAfterRedirects, undefined)); 54 | // Cleanup 55 | c.onDestroy(() => subs.unsubscribe()); 56 | }; 57 | } 58 | 59 | /** Converts all path rules from string to Regex instances */ 60 | function normalizePathRules(rules: Array): Array { 61 | return rules.map(rule => (rule instanceof RegExp) 62 | ? rule 63 | : new RegExp(`^${rule.replace('*', '.*')}$`, 'i')); 64 | } 65 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | * [15.0.0](#1500) 4 | * [14.0.0](#1400) 5 | * [13.0.1](#1301) 6 | * [13.0.0](#1300) 7 | * [12.0.0](#1200) 8 | * [11.2.1](#1121) 9 | * [11.2.0](#1120) 10 | * [11.1.0](#1110) 11 | * [11.0.0](#1100) 12 | * [10.0.0](#1000) 13 | * [9.2.0](#920) 14 | * [9.1.1](#911) 15 | * [9.1.0](#910) 16 | * [9.0.1](#901) 17 | * [9.0.0](#900) 18 | * [8.1.0](#810) 19 | 20 | ## 15.0.0 21 | 22 | - `GA4` support 23 | - Updated Angular to `v16` and dropped support for older versions 24 | - Dropped `rxjs` `v6` support. Now only `rxjs` `v7` is supported. 25 | - `NgxGoogleAnalyticsModule.forRoot()` and `NgxGoogleAnalyticsRouterModule.forRoot()` are 26 | now replaced by `provideGoogleAnalytics()` and `provideGoogleAnalyticsRouter()` to set up library's providers 27 | - All directives are now standalone and can be imported separately or used as `hostDirectives` in other directives 28 | - `GoogleAnalyticsService` methods now use options objects to specify additional arguments instead of a long list 29 | of arguments with some of the arguments `undefined`. 30 | 31 | Thanks, @Spejik, for the implementation. 32 | 33 | Before: 34 | ```ts 35 | this.gaService.pageView('/test', 'Test the Title', undefined, { 36 | user_id: 'my-user-id' 37 | }); 38 | ``` 39 | After: 40 | ```ts 41 | this.gaService.pageView('/test', { 42 | title: 'Test the Title', 43 | options: { 44 | user_id: 'my-user-id' 45 | } 46 | }); 47 | ``` 48 | 49 | ## 14.0.0 50 | 51 | * Added additional optional parameters 52 | * Update to support angular 14 (#96) 53 | 54 | ## 13.0.1 55 | 56 | * Bump Karma 57 | * Bump Jasmine 58 | * Bump RXJS to 7.4.0 59 | * Migrate from TSLint to ESLint 60 | 61 | ## 13.0.0 62 | 63 | * Bump to ng v13 64 | 65 | ## 12.0.0 66 | 67 | * Bump to ng v12 68 | 69 | ## 11.2.1 70 | 71 | * Allow override initial commands 72 | 73 | ## 11.2.0 74 | 75 | * Fixed parameter initCommands on NgxGoogleAnalyticsModule.forRoot() #46 76 | * Allow directive gaBind to trigger on any kind of event. #43 77 | 78 | ## 11.1.0 79 | 80 | * Using enum instead of string type (#38) 81 | 82 | ## 11.0.0 83 | 84 | * Bump to ng v11 85 | 86 | ## 10.0.0 87 | 88 | * Bump to ng v10 89 | 90 | ## 9.2.0 91 | 92 | * Add include/exclude rules feature on NgxGoogleAnalyticsRouterModule.forRoot() to filter witch pages should trigger page-view event. 93 | * Remove `peerDependencies` from package.json to do not trigger unnecessary warnings on `npm install` command. 94 | 95 | ## 9.1.1 96 | 97 | * [Bugfix] Set nonce using `setAttribute` 98 | 99 | ## 9.1.0 100 | 101 | * Add nonce 102 | * Fix typos 103 | * Rename i-google-analytics-command.ts 104 | 105 | ## 9.0.1 106 | 107 | * Created set() method at GoogleAnalyticsService (https://developers.google.com/analytics/devguides/collection/gtagjs/setting-values); 108 | * Changed gtag() method signature at GoogleAnalyticsService to accept anything; 109 | * Added a filter to remove undefined values to rest parameter on gtag() fn; 110 | 111 | ## 9.0.0 112 | 113 | Just bump to Angular ^9.x 114 | 115 | ## 8.1.0 116 | 117 | * Created and Updated unit tests on library project; 118 | * Created an automated workflow to run unit tests on each PR; 119 | * Created TypeDocs on all Services, Modules and Directives to help you guys to use this lib; 120 | * Removed bad practices on access Window and Document objects directly by Angular Services. I decided to create Injection Tokens to resolve those Browser Objects.; 121 | * Added some validations to ensure it is a Browser Environment; 122 | * Added cleanup code on NgxGoogleAnalyticsRouterModule. In short, we now unsubscribe Router events when bootstrap app is destroyed; 123 | * Added a new Settings property `ennableTracing` to log on console Errors and Warnings about `gtag()` calls; 124 | * Now we have `InjectionToken` for everything. You can replace all our default settings; 125 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/initializers/google-analytics-router.initializer.spec.ts: -------------------------------------------------------------------------------- 1 | import {ComponentRef, Injector} from '@angular/core'; 2 | import {TestBed} from '@angular/core/testing'; 3 | import {NavigationEnd, NavigationStart, Router, Event} from '@angular/router'; 4 | import {Subject} from 'rxjs'; 5 | import {GoogleAnalyticsService} from '../services/google-analytics.service'; 6 | import {GoogleAnalyticsRouterInitializer} from './google-analytics-router.initializer'; 7 | 8 | describe('googleAnalyticsRouterInitializer(settings, gaService)', () => { 9 | 10 | function fakeTransform(obj: Partial): Dest { 11 | return obj as Dest; 12 | } 13 | 14 | let gaService: GoogleAnalyticsService, 15 | spyOnGaService: jest.SpyInstance, 16 | router$: Subject, 17 | router: Router, 18 | component: ComponentRef; 19 | 20 | beforeEach(() => { 21 | gaService = TestBed.inject(GoogleAnalyticsService); 22 | spyOnGaService = jest.spyOn(gaService, 'pageView'); 23 | router$ = new Subject(); 24 | router = fakeTransform({ 25 | events: router$ 26 | }); 27 | component = fakeTransform>({ 28 | injector: fakeTransform({ 29 | get: () => router 30 | }), 31 | onDestroy: () => {} 32 | }); 33 | }); 34 | 35 | it('should not trigger multiple duplicated page views', async () => { 36 | await GoogleAnalyticsRouterInitializer(null, gaService)(component); 37 | 38 | // act 39 | router$.next(new NavigationStart(1, '/test')); 40 | router$.next(new NavigationEnd(1, '/test', '/test')); 41 | router$.next(new NavigationEnd(1, '/test', '/test')); 42 | 43 | // asserts 44 | expect(spyOnGaService).toHaveBeenCalledTimes(1); 45 | expect(spyOnGaService).toHaveBeenCalledWith('/test', undefined); 46 | }); 47 | 48 | it('should trigger only included route', async () => { 49 | await GoogleAnalyticsRouterInitializer({include: ['/test']}, gaService)(component); 50 | 51 | // act 52 | router$.next(new NavigationStart(1, '/test')); 53 | router$.next(new NavigationEnd(1, '/test', '/test')); 54 | router$.next(new NavigationEnd(1, '/test', '/test')); 55 | router$.next(new NavigationStart(1, '/test1')); 56 | router$.next(new NavigationEnd(1, '/test1', '/test1')); 57 | router$.next(new NavigationStart(1, '/test2')); 58 | router$.next(new NavigationEnd(1, '/test2', '/test2')); 59 | 60 | // asserts 61 | expect(spyOnGaService).toHaveBeenCalledTimes(1); 62 | expect(spyOnGaService).toHaveBeenCalledWith('/test', undefined); 63 | }); 64 | 65 | it('should not trigger excluded route', async () => { 66 | await GoogleAnalyticsRouterInitializer({exclude: ['/test']}, gaService)(component); 67 | 68 | // act 69 | router$.next(new NavigationStart(1, '/test1')); 70 | router$.next(new NavigationEnd(1, '/test1', '/test1')); 71 | router$.next(new NavigationStart(1, '/test2')); 72 | router$.next(new NavigationEnd(1, '/test2', '/test2')); 73 | router$.next(new NavigationStart(1, '/test')); 74 | router$.next(new NavigationEnd(1, '/test', '/test')); 75 | router$.next(new NavigationEnd(1, '/test', '/test')); 76 | 77 | // asserts 78 | expect(spyOnGaService).toHaveBeenCalledTimes(1); 79 | expect(spyOnGaService).toHaveBeenCalledWith('/test2', undefined); 80 | }); 81 | 82 | it('should work w/ include and exclude router', async () => { 83 | await GoogleAnalyticsRouterInitializer({ 84 | include: ['/test*'], 85 | exclude: ['/test-2'] 86 | }, 87 | gaService 88 | )(component); 89 | 90 | // act 91 | router$.next(new NavigationStart(1, '/test-1')); 92 | router$.next(new NavigationEnd(1, '/test-1', '/test-1')); 93 | router$.next(new NavigationEnd(1, '/test-1', '/test-1')); 94 | router$.next(new NavigationStart(1, '/test-2')); 95 | router$.next(new NavigationEnd(1, '/test-2', '/test-2')); 96 | 97 | // asserts 98 | expect(spyOnGaService).toHaveBeenCalledTimes(1); 99 | expect(spyOnGaService).toHaveBeenCalledWith('/test-1', undefined); 100 | }); 101 | 102 | test.each([ 103 | { 104 | path: '/test-1', 105 | matchType: 'simple uri' 106 | }, 107 | { 108 | path: '/test*', 109 | matchType: 'wildcard uri' 110 | }, 111 | { 112 | path: '/test.*', 113 | matchType: 'regex uri' 114 | } 115 | ])('should match $matchType', async ({path}) => { 116 | await GoogleAnalyticsRouterInitializer({ 117 | include: [path] 118 | }, 119 | gaService 120 | )(component); 121 | 122 | // act 123 | router$.next(new NavigationStart(1, '/test-1')); 124 | router$.next(new NavigationEnd(1, '/test-1', '/test-1')); 125 | router$.next(new NavigationEnd(1, '/test-1', '/test-1')); 126 | 127 | // asserts 128 | expect(spyOnGaService).toHaveBeenCalledTimes(1); 129 | expect(spyOnGaService).toHaveBeenCalledWith('/test-1', undefined); 130 | }); 131 | 132 | }); 133 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngx-google-analytics-sdk": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "prefix": "app", 11 | "schematics": {}, 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/ngx-google-analytics-sdk", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": ["zone.js"], 20 | "tsConfig": "src/tsconfig.app.json", 21 | "assets": [ 22 | "src/favicon.ico", 23 | "src/assets" 24 | ], 25 | "styles": [ 26 | "src/styles.css" 27 | ], 28 | "scripts": [], 29 | "vendorChunk": true, 30 | "extractLicenses": false, 31 | "buildOptimizer": false, 32 | "sourceMap": true, 33 | "optimization": false, 34 | "namedChunks": true 35 | }, 36 | "configurations": { 37 | "production": { 38 | "budgets": [ 39 | { 40 | "type": "anyComponentStyle", 41 | "maximumWarning": "6kb" 42 | } 43 | ], 44 | "fileReplacements": [ 45 | { 46 | "replace": "src/environments/environment.ts", 47 | "with": "src/environments/environment.prod.ts" 48 | } 49 | ], 50 | "optimization": true, 51 | "outputHashing": "all", 52 | "sourceMap": false, 53 | "namedChunks": false, 54 | "extractLicenses": true, 55 | "vendorChunk": false, 56 | "buildOptimizer": true 57 | }, 58 | "development": {} 59 | }, 60 | "defaultConfiguration": "production" 61 | }, 62 | "serve": { 63 | "builder": "@angular-devkit/build-angular:dev-server", 64 | "options": {}, 65 | "configurations": { 66 | "production": { 67 | "browserTarget": "ngx-google-analytics-sdk:build:production" 68 | }, 69 | "development": { 70 | "browserTarget": "ngx-google-analytics-sdk:build:development" 71 | } 72 | }, 73 | "defaultConfiguration": "development" 74 | }, 75 | "extract-i18n": { 76 | "builder": "@angular-devkit/build-angular:extract-i18n", 77 | "options": { 78 | "browserTarget": "ngx-google-analytics-sdk:build" 79 | } 80 | }, 81 | "test": { 82 | "builder": "@angular-builders/jest:run", 83 | "options": { 84 | "configPath": "src/jest.config.js", 85 | "tsConfig": "src/tsconfig.spec.json" 86 | } 87 | }, 88 | "lint": { 89 | "builder": "@angular-eslint/builder:lint", 90 | "options": { 91 | "lintFilePatterns": [ 92 | "src/**/*.ts", 93 | "src/**/*.html" 94 | ] 95 | } 96 | } 97 | } 98 | }, 99 | "ngx-google-analytics-sdk-e2e": { 100 | "root": "e2e/", 101 | "projectType": "application", 102 | "architect": { 103 | "e2e": { 104 | "builder": "@angular-devkit/build-angular:protractor", 105 | "options": { 106 | "protractorConfig": "e2e/protractor.conf.js" 107 | }, 108 | "configurations": { 109 | "production": { 110 | "devServerTarget": "ngx-google-analytics-sdk:serve:production" 111 | }, 112 | "development": { 113 | "devServerTarget": "ngx-google-analytics-sdk:serve:development" 114 | } 115 | }, 116 | "defaultConfiguration": "development" 117 | } 118 | } 119 | }, 120 | "ngx-google-analytics": { 121 | "root": "projects/ngx-google-analytics", 122 | "sourceRoot": "projects/ngx-google-analytics/src", 123 | "projectType": "library", 124 | "prefix": "ga", 125 | "architect": { 126 | "build": { 127 | "builder": "@angular-devkit/build-angular:ng-packagr", 128 | "options": { 129 | "tsConfig": "projects/ngx-google-analytics/tsconfig.lib.json", 130 | "project": "projects/ngx-google-analytics/ng-package.json" 131 | }, 132 | "configurations": { 133 | "production": { 134 | "tsConfig": "projects/ngx-google-analytics/tsconfig.lib.prod.json" 135 | }, 136 | "development": {} 137 | }, 138 | "defaultConfiguration": "production" 139 | }, 140 | "test": { 141 | "builder": "@angular-builders/jest:run", 142 | "options": { 143 | "configPath": "jest.config.js", 144 | "tsConfig": "tsconfig.spec.json" 145 | } 146 | }, 147 | "lint": { 148 | "builder": "@angular-eslint/builder:lint", 149 | "options": { 150 | "lintFilePatterns": [ 151 | "projects/ngx-google-analytics/**/*.ts", 152 | "projects/ngx-google-analytics/**/*.html" 153 | ] 154 | } 155 | } 156 | } 157 | } 158 | }, 159 | "cli": { 160 | "analytics": "3ce39043-c695-45ae-9eeb-07fed5fb3a2e", 161 | "schematicCollections": [ 162 | "@angular-eslint/schematics" 163 | ] 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/services/google-analytics.service.ts: -------------------------------------------------------------------------------- 1 | import {DOCUMENT} from '@angular/common'; 2 | import {inject, Injectable, isDevMode} from '@angular/core'; 3 | import {GaActionEnum} from '../enums/ga-action.enum'; 4 | import { 5 | IGoogleAnalyticsServiceAppView, 6 | IGoogleAnalyticsServiceEvent, 7 | IGoogleAnalyticsServicePageView 8 | } from '../interfaces/i-google-analytics-sevice'; 9 | import {NGX_GOOGLE_ANALYTICS_SETTINGS_TOKEN} from '../tokens/ngx-google-analytics-settings-token'; 10 | import {NGX_GTAG_FN} from '../tokens/ngx-gtag-token'; 11 | import {Primitive} from '../types/primitive.type'; 12 | import {GtagFn} from '../types/gtag.type'; 13 | 14 | @Injectable({ 15 | providedIn: 'root' 16 | }) 17 | export class GoogleAnalyticsService { 18 | 19 | private readonly settings = inject(NGX_GOOGLE_ANALYTICS_SETTINGS_TOKEN); 20 | private readonly _document = inject(DOCUMENT); 21 | private readonly _gtag = inject(NGX_GTAG_FN); 22 | 23 | private get document(): Document { 24 | return this._document; 25 | } 26 | 27 | /** 28 | * Call native GA Tag 29 | */ 30 | gtag: GtagFn = (...args) => { 31 | try { 32 | this._gtag(...args.filter(x => x !== undefined)); 33 | } catch (err) { 34 | this.throw(err); 35 | } 36 | }; 37 | 38 | /** 39 | * Send an event trigger to GA. This is the same as: 40 | * ```js 41 | * gtag('event', 'video_auto_play_start', { 42 | * 'event_label': 'My promotional video', 43 | * 'event_category': 'video_auto_play' 44 | * }); 45 | * ``` 46 | * 47 | * @param action 'video_auto_play_start' 48 | * @param options event options (category, label, value, interaction, [custom dimensions] options) 49 | */ 50 | event(action: GaActionEnum | string, options?: IGoogleAnalyticsServiceEvent) { 51 | try { 52 | const opt = new Map(); 53 | if (options?.category !== undefined) { 54 | opt.set('event_category', options.category); 55 | } 56 | if (options?.label !== undefined) { 57 | opt.set('event_label', options.label); 58 | } 59 | if (options?.value !== undefined) { 60 | opt.set('value', options.value); 61 | } 62 | if (options?.interaction !== undefined) { 63 | opt.set('interaction', options.interaction); 64 | } 65 | if (options?.options !== undefined) { 66 | Object 67 | .entries(options.options) 68 | .map(([key, value]) => opt.set(key, value)); 69 | } 70 | const params = this.toKeyValue(opt); 71 | if (params) { 72 | this.gtag('event', action as string, params); 73 | } else { 74 | this.gtag('event', action as string); 75 | } 76 | } catch (error) { 77 | this.throw(error); 78 | } 79 | } 80 | 81 | /** 82 | * Send a page view event. This is the same as: 83 | * 84 | * ```js 85 | * gtag('config', 'GA_TRACKING_ID', { 86 | * 'page_title' : 'Homepage', 87 | * 'page_path': '/home' 88 | * }); 89 | * ``` 90 | * 91 | * The tracking ID is injected automatically by Inject Token NGX_GOOGLE_ANALYTICS_SETTINGS_TOKEN 92 | * 93 | * @param path /home 94 | * @param options pageView options (title, location, [custom dimensions] options) 95 | */ 96 | pageView(path: string, options?: IGoogleAnalyticsServicePageView) { 97 | try { 98 | const opt = new Map([['page_path', path]]); 99 | if (options?.title !== undefined) { 100 | opt.set('page_title', options.title); 101 | } 102 | if (options?.location !== undefined || this.document) { 103 | opt.set('page_location', (options?.location ?? this.document.location.href)); 104 | } 105 | if (options?.options !== undefined) { 106 | Object 107 | .entries(options.options) 108 | .map(([key, value]) => opt.set(key, value)); 109 | } 110 | this.gtag('event', 'page_view', this.toKeyValue(opt)); 111 | } catch (error) { 112 | this.throw(error); 113 | } 114 | } 115 | 116 | /** 117 | * Send an event to report a App Page View. This is the same as: 118 | * 119 | * ```js 120 | * gtag('event', 'screen_view', { 121 | * 'app_name': 'myAppName', 122 | * 'screen_name' : 'Home' 123 | * }); 124 | * 125 | * ``` 126 | * 127 | * @param screen 'screen_name' 128 | * @param appName 'app_name' 129 | * @param options appView options (appId, appVersion, installerId) 130 | */ 131 | appView(screen: string, appName: string, options?: IGoogleAnalyticsServiceAppView) { 132 | try { 133 | const opt = new Map([['screen_name', screen], ['app_name', appName]]); 134 | if (options?.appId !== undefined) { 135 | opt.set('app_id', options.appId); 136 | } 137 | if (options?.appVersion !== undefined) { 138 | opt.set('app_version', options.appVersion); 139 | } 140 | if (options?.installerId !== undefined) { 141 | opt.set('app_installer_id', options.installerId); 142 | } 143 | this.gtag('event', 'screen_view', this.toKeyValue(opt)); 144 | } catch (error) { 145 | this.throw(error); 146 | } 147 | } 148 | 149 | // noinspection SpellCheckingInspection 150 | /** 151 | * Defines persistent values on GoogleAnalytics 152 | * 153 | * @see https://developers.google.com/analytics/devguides/collection/gtagjs/setting-values 154 | * 155 | * ```js 156 | * gtag('set', { 157 | * 'currency': 'USD', 158 | * 'country': 'US' 159 | * }); 160 | * ``` 161 | */ 162 | set(options: Record) { 163 | try { 164 | this._gtag('set', options); 165 | } catch (err) { 166 | this.throw(err); 167 | } 168 | } 169 | 170 | /** 171 | * Send an event to GA to report an application error. This is the same as: 172 | * 173 | * ```js 174 | * gtag('event', 'exception', { 175 | * 'description': 'error_description', 176 | * 'fatal': false // set to true if the error is fatal 177 | * }); 178 | * ``` 179 | * 180 | * @param description 'error_description' 181 | * @param fatal set to true if the error is fatal 182 | */ 183 | exception(description?: string, fatal?: boolean) { 184 | try { 185 | const opt = new Map(); 186 | if (description !== undefined) { 187 | opt.set('description', description); 188 | } 189 | if (fatal !== undefined) { 190 | opt.set('fatal', fatal); 191 | } 192 | const params = this.toKeyValue(opt); 193 | if (params) { 194 | this.gtag('event', 'app_exception', params); 195 | } else { 196 | this.gtag('event', 'app_exception'); 197 | } 198 | } catch (error) { 199 | this.throw(error); 200 | } 201 | } 202 | 203 | private throw(err: Error) { 204 | if ((this.settings.enableTracing || isDevMode()) && console && console.error) { 205 | console.error(err); 206 | } 207 | } 208 | 209 | private toKeyValue(map: Map): { [param: string]: Primitive } | undefined { 210 | if (map.size) // > 0 211 | return Object.fromEntries(map); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/services/google-analytics.service.spec.ts: -------------------------------------------------------------------------------- 1 | import {TestBed} from '@angular/core/testing'; 2 | import {GoogleAnalyticsService} from './google-analytics.service'; 3 | import {provideGoogleAnalytics} from '../ngx-google-analytics.provider'; 4 | import {DataLayer} from '../types/data-layer.type'; 5 | import {GtagFnArgs} from '../types/gtag.type'; 6 | import {GaWindow} from '../tokens/ngx-google-analytics-window'; 7 | 8 | describe('GoogleAnalyticsService', () => { 9 | 10 | const globalWindow: GaWindow = window; 11 | 12 | globalWindow.dataLayer = [] as DataLayer; 13 | 14 | globalWindow.gtag = function (...args: GtagFnArgs): void { 15 | globalWindow.dataLayer.push(args); 16 | }; 17 | 18 | const tracking = 'GA-000000000'; 19 | let spyOnGtag: jest.SpyInstance; 20 | 21 | beforeEach(() => { 22 | TestBed.configureTestingModule({ 23 | providers: [ 24 | provideGoogleAnalytics(tracking) 25 | ] 26 | }); 27 | }); 28 | 29 | beforeEach(() => { 30 | spyOnGtag = jest.spyOn(globalWindow, 'gtag'); 31 | }); 32 | 33 | it('should call gtag fn w/ action/command pair', () => { 34 | const action = 'action', 35 | command = 'command', 36 | ga = TestBed.inject(GoogleAnalyticsService); 37 | // act 38 | ga.gtag(action, command); 39 | // specs 40 | expect(spyOnGtag).toHaveBeenCalledWith(action, command); 41 | }); 42 | 43 | describe('gtag(`event`)', () => { 44 | 45 | it('should call a `event` action on gtag command', () => { 46 | const action = 'video_auto_play_start', 47 | ga = TestBed.inject(GoogleAnalyticsService); 48 | 49 | ga.event(action); 50 | 51 | expect(spyOnGtag).toHaveBeenLastCalledWith('event', action); 52 | }); 53 | 54 | it('should find `event_category` property on gtag command', () => { 55 | const action = 'video_auto_play_start', 56 | event_category = 'video_auto_play', 57 | ga = TestBed.inject(GoogleAnalyticsService); 58 | 59 | ga.event(action, {category: event_category}); 60 | 61 | expect(spyOnGtag).toHaveBeenCalledWith('event', action, {event_category}); 62 | }); 63 | 64 | it('should find `event_label` property on gtag command', () => { 65 | const action = 'video_auto_play_start', 66 | event_label = 'My promotional video', 67 | ga = TestBed.inject(GoogleAnalyticsService); 68 | 69 | ga.event(action, {label: event_label}); 70 | 71 | expect(spyOnGtag).toHaveBeenCalledWith('event', action, {event_label}); 72 | }); 73 | 74 | it('should find `value` property on gtag command', () => { 75 | const action = 'video_auto_play_start', 76 | value = 40, 77 | ga = TestBed.inject(GoogleAnalyticsService); 78 | 79 | ga.event(action, {value}); 80 | 81 | expect(spyOnGtag).toHaveBeenCalledWith('event', action, {value}); 82 | }); 83 | 84 | it('should find `interaction` property on gtag command', () => { 85 | const action = 'video_auto_play_start', 86 | interaction = true, 87 | ga = TestBed.inject(GoogleAnalyticsService); 88 | 89 | ga.event(action, {interaction}); 90 | 91 | expect(spyOnGtag).toHaveBeenCalledWith('event', action, {interaction}); 92 | }); 93 | 94 | }); 95 | 96 | describe('gtag(`config`) aka pageView', () => { 97 | 98 | it('should call a `config` action on gtag command', () => { 99 | const page_path = '/page.html', 100 | ga = TestBed.inject(GoogleAnalyticsService); 101 | 102 | ga.pageView(page_path); 103 | 104 | expect(spyOnGtag).toHaveBeenCalledWith('event', 'page_view', { 105 | page_path, 106 | page_location: document.location.href 107 | }); 108 | }); 109 | 110 | it('should send `page_title` attribute on gtag command', () => { 111 | const page_path = '/page.html', 112 | page_title = 'My Page View', 113 | ga = TestBed.inject(GoogleAnalyticsService); 114 | 115 | ga.pageView(page_path, {title: page_title}); 116 | 117 | expect(spyOnGtag).toHaveBeenCalledWith('event', 'page_view', { 118 | page_path, 119 | page_title, 120 | page_location: document.location.href 121 | }); 122 | }); 123 | 124 | it('should send `page_location` attribute on gtag command', () => { 125 | const page_path = '/page.html', 126 | page_location = 'my location', 127 | ga = TestBed.inject(GoogleAnalyticsService); 128 | 129 | ga.pageView(page_path, {location: page_location}); 130 | 131 | expect(spyOnGtag).toHaveBeenCalledWith('event', 'page_view', {page_path, page_location}); 132 | }); 133 | 134 | it('should use `document.location.href` as a default `page_location`', () => { 135 | const page_path = '/page.html', 136 | ga = TestBed.inject(GoogleAnalyticsService); 137 | 138 | ga.pageView(page_path, undefined); 139 | 140 | expect(spyOnGtag).toHaveBeenCalledWith('event', 'page_view', { 141 | page_path, 142 | page_location: document.location.href 143 | }); 144 | }); 145 | 146 | }); 147 | 148 | describe('gtag(`event`)', () => { 149 | 150 | it('should call a `event` action on gtag command', () => { 151 | const screen_name = 'Home Screen', 152 | app_name = 'My App', 153 | ga = TestBed.inject(GoogleAnalyticsService); 154 | 155 | ga.appView(screen_name, app_name); 156 | 157 | expect(spyOnGtag).toHaveBeenCalledWith('event', 'screen_view', {screen_name, app_name}); 158 | }); 159 | 160 | it('should send `app_id` property on gtag command', () => { 161 | const screen_name = 'Home Screen', 162 | app_name = 'My App', 163 | app_id = '2333', 164 | ga = TestBed.inject(GoogleAnalyticsService); 165 | 166 | ga.appView(screen_name, app_name, {appId: app_id}); 167 | 168 | expect(spyOnGtag).toHaveBeenCalledWith('event', 'screen_view', {screen_name, app_name, app_id}); 169 | }); 170 | 171 | it('should send `app_version` property on gtag command', () => { 172 | const screen_name = 'Home Screen', 173 | app_name = 'My App', 174 | app_version = 'v1.0', 175 | ga = TestBed.inject(GoogleAnalyticsService); 176 | 177 | ga.appView(screen_name, app_name, {appVersion: app_version}); 178 | 179 | expect(spyOnGtag).toHaveBeenCalledWith('event', 'screen_view', {screen_name, app_name, app_version}); 180 | }); 181 | 182 | it('should send `app_installer_id` property on gtag command', () => { 183 | const screen_name = 'Home Screen', 184 | app_name = 'My App', 185 | app_installer_id = '30000', 186 | ga = TestBed.inject(GoogleAnalyticsService); 187 | 188 | ga.appView(screen_name, app_name, {installerId: app_installer_id}); 189 | 190 | expect(spyOnGtag).toHaveBeenCalledWith('event', 'screen_view', {screen_name, app_name, app_installer_id}); 191 | }); 192 | 193 | }); 194 | 195 | describe('gtag(`event`, `exception`)', () => { 196 | 197 | it('should call `event` action w/ `exception type on gtag command`', () => { 198 | const ga = TestBed.inject(GoogleAnalyticsService); 199 | 200 | ga.exception(); 201 | 202 | expect(spyOnGtag).toHaveBeenCalledWith('event', 'app_exception'); 203 | }); 204 | 205 | it('should send `description` attribute on gtag command', () => { 206 | const description = 'Something went wrong', 207 | ga = TestBed.inject(GoogleAnalyticsService); 208 | 209 | ga.exception(description); 210 | 211 | expect(spyOnGtag).toHaveBeenCalledWith('event', 'app_exception', expect.objectContaining({description})); 212 | }); 213 | 214 | it('should send `fatal` attribute on gtag command', () => { 215 | const fatal = true, 216 | ga = TestBed.inject(GoogleAnalyticsService); 217 | 218 | ga.exception(undefined, fatal); 219 | 220 | expect(spyOnGtag).toHaveBeenCalledWith('event', 'app_exception', expect.objectContaining({fatal})); 221 | }); 222 | 223 | }); 224 | 225 | describe('gtag(`set`, ...)', () => { 226 | 227 | it('should send `set` command on gtag() call', () => { 228 | const setData = {currency: 'USD', country: 'US'}, 229 | ga = TestBed.inject(GoogleAnalyticsService); 230 | 231 | ga.set(setData); 232 | 233 | expect(spyOnGtag).toHaveBeenCalledWith('set', setData); 234 | }); 235 | }); 236 | 237 | }); 238 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ngx Google Analytics 2 | 3 | > A simple way to track GA4 events in Angular apps. 4 | 5 | `@hakimio/ngx-google-analytics` is a fork of __Max Andriani's__ `ngx-google-analytics`. 6 | 7 | --- 8 | 9 | [![Build Status](https://img.shields.io/github/actions/workflow/status/hakimio/ngx-google-analytics/tests.yml)](https://github.com/hakimio/ngx-google-analytics/actions/workflows/tests.yml) 10 | [![Version Number](https://img.shields.io/npm/v/@hakimio/ngx-google-analytics.svg)](https://www.npmjs.com/package/@hakimio/ngx-google-analytics) 11 | [![License](https://img.shields.io/npm/l/@hakimio/ngx-google-analytics.svg)](https://www.npmjs.com/package/@hakimio/ngx-google-analytics) 12 | 13 | # Index 14 | 15 | - [Setup](#setup) 16 | - [Install the package](#install-the-package) 17 | - [Standalone](#standalone-app-component) 18 | - [NgModule](#ngmodule) 19 | - [Setup Router Provider](#setup-router-provider) 20 | - [Advanced Router Provider Setup](#advanced-router-provider-setup) 21 | - [GoogleAnalyticsService](#googleanalyticsservice) 22 | - [Register Analytics events](#register-analytics-events) 23 | - [Manually register page views](#manually-register-page-views) 24 | - [Directives](#directives) 25 | - [Simple directive usage](#simple-directive-usage) 26 | - [Usage on input elements](#usage-on-input-elements) 27 | - [Directive groups](#directive-groups) 28 | 29 | ## Setup 30 | 31 | ### Install the package 32 | 33 | ``` 34 | npm install @hakimio/ngx-google-analytics 35 | ``` 36 | 37 | ### Standalone app component 38 | 39 | If your app component is using standalone API, follow these steps to set up the library: 40 | - Add `provideGoogleAnalytics('ga4-tag-id')` to your app's providers. If you can not find your GA4 tag id, see [this](https://support.google.com/analytics/answer/9539598?sjid=1584949217252276099-EU) Google help page. 41 | 42 | `main.ts` 43 | ```ts 44 | import {AppComponent} from './app/app.component'; 45 | import {bootstrapApplication} from '@angular/platform-browser'; 46 | import {ROUTES} from './app/app.routes'; 47 | import {provideGoogleAnalytics} from '@hakimio/ngx-google-analytics'; 48 | 49 | bootstrapApplication(AppComponent, { 50 | providers: [ 51 | provideRouter(ROUTES), 52 | provideAnimations(), 53 | provideGoogleAnalytics('ga4-tag-id') // ⬅️ Google Analytics provider 54 | ] 55 | }).catch(err => console.error(err)); 56 | ``` 57 | You can also specify additional settings using the second optional parameter: `provideGoogleAnalytics('ga4-tag-id', settings)`. 58 | See [IGoogleAnalyticsSettings](https://github.com/hakimio/ngx-google-analytics/blob/master/projects/ngx-google-analytics/src/lib/interfaces/i-google-analytics-settings.ts) 59 | interface for more information about available settings. 60 | - Add `NgxGoogleAnalyticsModule` to your app component's imports: 61 | 62 | `app.component.ts` 63 | ```ts 64 | import {NgxGoogleAnalyticsModule} from '@hakimio/ngx-google-analytics'; 65 | 66 | @Component({ 67 | selector: 'app-root', 68 | templateUrl: './app.component.html', 69 | styleUrls: ['./app.component.css'], 70 | standalone: true, 71 | imports: [ 72 | NgxGoogleAnalyticsModule // ⬅️ Google Analytics module 73 | ] 74 | }) 75 | export class AppComponent {} 76 | ``` 77 | 78 | ### NgModule 79 | 80 | If your application is `NgModule` based, follow these steps to set up the library: 81 | - Add `NgxGoogleAnalyticsModule` to your root app module's (`AppModule`) `imports`. 82 | - Add `provideGoogleAnalytics('ga4-tag-id')` in your app module's providers. If you can not find your GA4 tag id, see [this](https://support.google.com/analytics/answer/9539598?sjid=1584949217252276099-EU) Google help page. 83 | 84 | `app.module.ts` 85 | ```ts 86 | import {NgxGoogleAnalyticsModule, provideGoogleAnalytics} from '@hakimio/ngx-google-analytics'; 87 | 88 | @NgModule({ 89 | declarations: [ 90 | AppComponent 91 | ], 92 | imports: [ 93 | BrowserModule, 94 | NgxGoogleAnalyticsModule // ⬅️ Google Analytics module 95 | ], 96 | providers: [ 97 | provideGoogleAnalytics('ga4-tag-id') // ⬅️ Google Analytics provider 98 | ], 99 | bootstrap: [AppComponent] 100 | }) 101 | export class AppModule {} 102 | ``` 103 | You can also specify additional settings using the second optional parameter: `provideGoogleAnalytics('ga4-tag-id', settings)`. 104 | See [IGoogleAnalyticsSettings](https://github.com/hakimio/ngx-google-analytics/blob/master/projects/ngx-google-analytics/src/lib/interfaces/i-google-analytics-settings.ts) 105 | interface for more information about available settings. 106 | 107 | ### Setup Router Provider 108 | 109 | If you are using Angular Router and would like to track page views, you can include `provideGoogleAnalyticsRouter()` in your root app providers. 110 | 111 | **IMPORTANT:** `provideGoogleAnalyticsRouter()` is not compatible with SSR and should not be included in server app providers. 112 | 113 | ```ts 114 | import {NgxGoogleAnalyticsModule, provideGoogleAnalytics, provideGoogleAnalyticsRouter} from '@hakimio/ngx-google-analytics'; 115 | 116 | @NgModule({ 117 | imports: [ 118 | // ... 119 | NgxGoogleAnalyticsModule // ⬅️ Google Analytics module 120 | ], 121 | providers: [ 122 | provideGoogleAnalytics('ga4-tag-id'), 123 | provideGoogleAnalyticsRouter() // ⬅️ Google Analytics router provider 124 | ] 125 | }) 126 | export class AppModule {} 127 | ``` 128 | 129 | ### Advanced Router Provider Setup 130 | 131 | You can include or exclude some routes by passing options object to `provideGoogleAnalyticsRouter(options)`. 132 | 133 | Following path matches are supported: 134 | 135 | - Simple path match: `{ include: [ '/full-uri-match' ] }`; 136 | - Wildcard path match: `{ include: [ '*/public/*' ] }`; 137 | - Regex path match: `{ include: [ /^\/public\/.*/ ] }`; 138 | 139 | ```ts 140 | import {NgxGoogleAnalyticsModule, provideGoogleAnalytics, provideGoogleAnalyticsRouter} from '@hakimio/ngx-google-analytics'; 141 | 142 | @NgModule({ 143 | imports: [ 144 | // ... 145 | NgxGoogleAnalyticsModule // ⬅️ Google Analytics module 146 | ], 147 | providers: [ 148 | provideGoogleAnalytics('ga4-tag-id'), 149 | provideGoogleAnalyticsRouter({ // ⬅️ Google Analytics router provider 150 | include: ['/some-path'], 151 | exclude: ['*/another/path/*'] 152 | }) 153 | ] 154 | }) 155 | export class AppModule {} 156 | ``` 157 | 158 | 159 | ## GoogleAnalyticsService 160 | 161 | The service provides strongly typed way to call `gtag()` command. Apart from type checking, it does not do 162 | any other validation or transformation of the parameters. 163 | 164 | ### Register Analytics events 165 | 166 | ```ts 167 | @Component() 168 | export class TestFormComponent { 169 | 170 | private readonly gaService = inject(GoogleAnalyticsService); 171 | 172 | onUserInputName() { 173 | this.gaService.event('enter_name', { 174 | category: 'user_register_form', 175 | label: 'Name', 176 | options: { 177 | customDimension: 'foo_bar' 178 | } 179 | }); 180 | } 181 | 182 | onUserInputEmail() { 183 | this.gaService.event('enter_email', { 184 | category: 'user_register_form', 185 | label: 'Email' 186 | }); 187 | } 188 | 189 | onSubmit() { 190 | this.gaService.event('submit', { 191 | category: 'user_register_form', 192 | label: 'Enviar' 193 | }); 194 | } 195 | 196 | } 197 | ``` 198 | 199 | ### Manually register page views 200 | 201 | ```ts 202 | @Component() 203 | export class TestPageComponent implements OnInit { 204 | 205 | private readonly gaService = inject(GoogleAnalyticsService); 206 | 207 | ngOnInit() { 208 | this.gaService.pageView('/test', { 209 | title: 'Test the Title' 210 | }); 211 | } 212 | 213 | onUserLogin() { 214 | this.gaService.pageView('/test', { 215 | title: 'Test the Title', 216 | options: { 217 | user_id: 'my-user-id' 218 | } 219 | }); 220 | } 221 | 222 | } 223 | ``` 224 | 225 | ## Directives 226 | 227 | Directives provide a simple way to register Analytics events. Instead of manually using `GoogleAnalyticsService`, 228 | you can simply add `ga*` attributes to your html elements. 229 | 230 | ### Simple directive usage 231 | 232 | By default, the directive calls `gtag()` on click events, but you can also specify other events by providing `gaBind` attribute. 233 | 234 | **IMPORTANT:** Remember to import `NgxGoogleAnalyticsModule` in all your standalone components and modules where you use `ga*` directives. 235 | 236 | ```html 237 |
238 | 239 | 240 | 241 | 242 |
243 | ``` 244 | 245 | ### Usage on `input` elements 246 | 247 | When `gaEvent` directive is used on form elements, the default `trigger` is `focus` event. 248 | 249 | ```html 250 |
251 | 252 |
253 | ``` 254 | 255 | ### Directive groups 256 | 257 | The `gaCategory` directive can be specified on higher level of html element group to specify same category for all 258 | child elements. 259 | 260 | ```html 261 |
262 | 263 | 264 | 265 |
266 | ``` 267 | -------------------------------------------------------------------------------- /projects/ngx-google-analytics/src/lib/directives/ga-event.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component} from '@angular/core'; 2 | import {ComponentFixture, TestBed} from '@angular/core/testing'; 3 | import {GaActionEnum} from '../enums/ga-action.enum'; 4 | import {NgxGoogleAnalyticsModule} from '../ngx-google-analytics.module'; 5 | import {GoogleAnalyticsService} from '../services/google-analytics.service'; 6 | import {GaEventDirective} from './ga-event.directive'; 7 | import {By} from '@angular/platform-browser'; 8 | 9 | describe('GaEventDirective', () => { 10 | 11 | @Component({ 12 | selector: 'ga-host', 13 | template: ` 14 | 22 | 31 | 40 | 49 | 58 | `, 59 | standalone: true, 60 | changeDetection: ChangeDetectionStrategy.OnPush, 61 | imports: [ 62 | NgxGoogleAnalyticsModule 63 | ] 64 | }) 65 | class HostComponent { 66 | gaAction: GaActionEnum | string; 67 | gaCategory: string; 68 | gaLabel: string; 69 | label: string; 70 | gaValue: number; 71 | gaInteraction: boolean; 72 | gaEvent: GaActionEnum | string; 73 | } 74 | 75 | let fixture: ComponentFixture, 76 | host: HostComponent; 77 | 78 | beforeEach(async () => { 79 | await TestBed.configureTestingModule({ 80 | imports: [ 81 | HostComponent 82 | ] 83 | }).compileComponents(); 84 | 85 | fixture = TestBed.createComponent(HostComponent); 86 | host = fixture.componentInstance; 87 | }); 88 | 89 | it('should create an instance', () => { 90 | const gaEvent = fixture 91 | .debugElement 92 | .query(By.css('button.test-1')) 93 | .injector 94 | .get(GaEventDirective); 95 | expect(gaEvent).toBeTruthy(); 96 | }); 97 | 98 | it('should call `trigger` on click event', () => { 99 | const ga: GoogleAnalyticsService = TestBed.inject(GoogleAnalyticsService), 100 | spyOnGa = jest.spyOn(ga, 'event'), 101 | input = fixture.debugElement.query(By.css('button.test-click')); 102 | 103 | fixture.detectChanges(); 104 | input.nativeElement.click(); 105 | fixture.detectChanges(); 106 | 107 | expect(spyOnGa).toHaveBeenCalledWith('test-1', expect.any(Object)); 108 | }); 109 | 110 | it('should call `trigger` on focus event', () => { 111 | const ga: GoogleAnalyticsService = TestBed.inject(GoogleAnalyticsService), 112 | spyOnGa = jest.spyOn(ga, 'event'), 113 | input = fixture.debugElement.query(By.css('button.test-focus')); 114 | 115 | fixture.detectChanges(); 116 | input.nativeElement.dispatchEvent(new FocusEvent('focus')); 117 | fixture.detectChanges(); 118 | 119 | expect(spyOnGa).toHaveBeenCalledWith('test-2', expect.any(Object)); 120 | }); 121 | 122 | it('should call `trigger` on blur event', () => { 123 | const ga: GoogleAnalyticsService = TestBed.inject(GoogleAnalyticsService), 124 | spyOnGa = jest.spyOn(ga, 'event'), 125 | input = fixture.debugElement.query(By.css('button.test-blur')); 126 | 127 | fixture.detectChanges(); 128 | input.nativeElement.dispatchEvent(new FocusEvent('blur')); 129 | fixture.detectChanges(); 130 | 131 | expect(spyOnGa).toHaveBeenCalledWith('test-3', expect.any(Object)); 132 | }); 133 | 134 | it('should call `trigger` on custom event', () => { 135 | const ga: GoogleAnalyticsService = TestBed.inject(GoogleAnalyticsService), 136 | spyOnGa = jest.spyOn(ga, 'event'), 137 | input = fixture.debugElement.query(By.css('button.test-custom')); 138 | 139 | fixture.detectChanges(); 140 | input.nativeElement.dispatchEvent(new CustomEvent('custom')); 141 | fixture.detectChanges(); 142 | 143 | expect(spyOnGa).toHaveBeenCalledWith('test-5', expect.any(Object)); 144 | }); 145 | 146 | it('should warn a message when try to call a event w/o gaEvent/gaAction value', () => { 147 | const spyOnConsole = jest.spyOn(console, 'warn'), 148 | input = fixture.debugElement.query(By.css('button.test-category')); 149 | 150 | fixture.detectChanges(); 151 | input.nativeElement.click(); 152 | fixture.detectChanges(); 153 | 154 | expect(spyOnConsole).toHaveBeenCalled(); 155 | }); 156 | 157 | it('should grab "gaAction" and pass to event trigger', () => { 158 | const ga: GoogleAnalyticsService = TestBed.inject(GoogleAnalyticsService), 159 | action = 'action-test', 160 | spyOnGa = jest.spyOn(ga, 'event'), 161 | input = fixture.debugElement.query(By.css('button.test-category')); 162 | 163 | host.gaAction = action; 164 | fixture.detectChanges(); 165 | input.nativeElement.click(); 166 | fixture.detectChanges(); 167 | 168 | expect(spyOnGa).toHaveBeenCalledWith(action, expect.objectContaining({category: 'test-4'})); 169 | }); 170 | 171 | it('should grab "gaEvent" and pass to event trigger', () => { 172 | const ga: GoogleAnalyticsService = TestBed.inject(GoogleAnalyticsService), 173 | action = 'action-t', 174 | spyOnGa = jest.spyOn(ga, 'event'), 175 | input = fixture.debugElement.query(By.css('button.test-category')); 176 | 177 | host.gaEvent = action; 178 | fixture.detectChanges(); 179 | input.nativeElement.click(); 180 | fixture.detectChanges(); 181 | 182 | expect(spyOnGa).toHaveBeenCalledWith(action, expect.objectContaining({category: 'test-4'})); 183 | }); 184 | 185 | it('should grab "gaCategory" and pass to event trigger', () => { 186 | const ga: GoogleAnalyticsService = TestBed.inject(GoogleAnalyticsService), 187 | action = 'action-test', 188 | category = 'category-test', 189 | spyOnGa = jest.spyOn(ga, 'event'), 190 | input = fixture.debugElement.query(By.css('button.test-category')); 191 | 192 | host.gaCategory = category; 193 | host.gaAction = action; 194 | fixture.detectChanges(); 195 | input.nativeElement.click(); 196 | fixture.detectChanges(); 197 | 198 | expect(spyOnGa).toHaveBeenCalledWith(action, expect.objectContaining({category})); 199 | }); 200 | 201 | it('should grab "gaLabel" and pass to event trigger', () => { 202 | const ga: GoogleAnalyticsService = TestBed.inject(GoogleAnalyticsService), 203 | action = 'action-test', 204 | label = 'label-test', 205 | spyOnGa = jest.spyOn(ga, 'event'), 206 | input = fixture.debugElement.query(By.css('button.test-category')); 207 | 208 | host.gaAction = action; 209 | host.gaLabel = label; 210 | fixture.detectChanges(); 211 | input.nativeElement.click(); 212 | fixture.detectChanges(); 213 | 214 | expect(spyOnGa).toHaveBeenCalledWith(action, expect.objectContaining({category: 'test-4', label})); 215 | }); 216 | 217 | it('should grab "label" and pass to event trigger', () => { 218 | const ga: GoogleAnalyticsService = TestBed.inject(GoogleAnalyticsService), 219 | action = 'action-t', 220 | label = 'label-t', 221 | spyOnGa = jest.spyOn(ga, 'event'), 222 | input = fixture.debugElement.query(By.css('button.test-category')); 223 | 224 | host.gaAction = action; 225 | host.label = label; 226 | fixture.detectChanges(); 227 | input.nativeElement.click(); 228 | fixture.detectChanges(); 229 | 230 | expect(spyOnGa).toHaveBeenCalledWith(action, expect.objectContaining({category: 'test-4', label})); 231 | }); 232 | 233 | it('should grab "gaValue" and pass to event trigger', () => { 234 | const ga: GoogleAnalyticsService = TestBed.inject(GoogleAnalyticsService), 235 | action = 'action-t', 236 | value = 40, 237 | spyOnGa = jest.spyOn(ga, 'event'), 238 | input = fixture.debugElement.query(By.css('button.test-category')); 239 | 240 | host.gaAction = action; 241 | host.gaValue = value; 242 | fixture.detectChanges(); 243 | input.nativeElement.click(); 244 | fixture.detectChanges(); 245 | 246 | expect(spyOnGa).toHaveBeenCalledWith(action, expect.objectContaining({category: 'test-4', value})); 247 | }); 248 | 249 | it('should grab "gaInteraction" and pass to event trigger', () => { 250 | const ga: GoogleAnalyticsService = TestBed.inject(GoogleAnalyticsService), 251 | action = 'action-t', 252 | gaInteraction = true, 253 | spyOnGa = jest.spyOn(ga, 'event'), 254 | input = fixture.debugElement.query(By.css('button.test-category')); 255 | 256 | host.gaAction = action; 257 | host.gaInteraction = gaInteraction; 258 | fixture.detectChanges(); 259 | input.nativeElement.click(); 260 | fixture.detectChanges(); 261 | 262 | expect(spyOnGa).toHaveBeenCalledWith(action, expect.objectContaining({ 263 | category: 'test-4', 264 | interaction: gaInteraction 265 | })); 266 | }); 267 | 268 | }); 269 | --------------------------------------------------------------------------------