diff --git a/.gitignore b/.gitignore index 2f85265bd..fca5b5041 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /tmp /out-tsc /bazel-out +/src/assets/config/config.json # Node /node_modules diff --git a/README.md b/README.md index b2c6bb3e9..8b4fbb3be 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ take up to 60 seconds once the docker build finishes. - Install git commit template: [Commit Template](docs/commit.template.md). - [Volta](#volta) +- 3rd-party tokens [Configuration](#configuration) ### Recommended @@ -59,3 +60,9 @@ npm run test:check-coverage-thresholds OSF uses volta to manage node and npm versions inside of the repository Install Volta from [volta](https://volta.sh/) and it will automatically pin Node/npm per the repo toolchain. + +## Configuration + +OSF uses an `assets/config/config.json` file for any 3rd-party tokens. This file is not committed to the repo. + +There is a `assets/config/template.json` file that can be copied to `assets/config/config.json` to store any 3rd-party tokens locally. diff --git a/package-lock.json b/package-lock.json index 912834b45..66961e984 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,9 @@ "@ngxs/logger-plugin": "^19.0.0", "@ngxs/store": "^19.0.0", "@primeng/themes": "^19.0.9", + "@sentry/angular": "^10.10.0", "ace-builds": "^1.42.0", + "angular-google-tag-manager": "^1.11.0", "cedar-artifact-viewer": "^0.9.5", "cedar-embeddable-editor": "^1.5.0", "chart.js": "^4.4.9", @@ -7406,6 +7408,101 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@sentry-internal/browser-utils": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.10.0.tgz", + "integrity": "sha512-209QN9vsQBwJcS+9DU7B4yl9mb4OqCt2kdL3LYDvqsuOdpICpwfowdK3RMn825Ruf4KLJa0KHM1scQbXZCc4lw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.10.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.10.0.tgz", + "integrity": "sha512-oSU4F/ebOsJA9Eof0me9hLpSDTSelpnEY6gmhU9sHyIG+U7hJRuCfeGICxQOzBtteepWRhAaZEv4s9ZBh3iD2w==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.10.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.10.0.tgz", + "integrity": "sha512-sKFYWBaft0ET6gd5B0pThR6gYTjaUECXCzVAnSYxy64a2/PK6lV93BtnA1C2Q34Yhv/0scdyIbZtfTnSsEgwUg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.10.0", + "@sentry/core": "10.10.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.10.0.tgz", + "integrity": "sha512-mJBNB0EBbE3vzL7lgd8lDoWWhRaRwxXdI4Kkx3r39u2+1qTdJP/xHbJDihyemCaw7gRL1FR/GC44JLipzEfkKQ==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "10.10.0", + "@sentry/core": "10.10.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/angular": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-10.10.0.tgz", + "integrity": "sha512-QlaVlkZHwAsZGWaWbCKAwrjFHB78IbExybVGl4wpuaJtZHUm7hS595jndTNeMW7yOjTXGINTlW5xRiSuuZ3tlw==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.10.0", + "@sentry/core": "10.10.0", + "tslib": "^2.4.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@angular/common": ">= 14.x <= 20.x", + "@angular/core": ">= 14.x <= 20.x", + "@angular/router": ">= 14.x <= 20.x", + "rxjs": "^6.5.5 || ^7.x" + } + }, + "node_modules/@sentry/browser": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.10.0.tgz", + "integrity": "sha512-STBs29meUk0CvluIOXXnnRGRtjKsJN9fAHS3dUu3GMjmow4rxKBiBbAwoPYftAVdfvGypT7zQCQ+K30dbRxp0g==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "10.10.0", + "@sentry-internal/feedback": "10.10.0", + "@sentry-internal/replay": "10.10.0", + "@sentry-internal/replay-canvas": "10.10.0", + "@sentry/core": "10.10.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.10.0.tgz", + "integrity": "sha512-4O1O6my/vYE98ZgfEuLEwOOuHzqqzfBT6IdRo1yiQM7/AXcmSl0H/k4HJtXCiCTiHm+veEuTDBHp0GQZmpIbtA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@sigstore/bundle": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-3.1.0.tgz", @@ -8987,6 +9084,19 @@ "typescript-eslint": "^8.0.0" } }, + "node_modules/angular-google-tag-manager": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/angular-google-tag-manager/-/angular-google-tag-manager-1.11.0.tgz", + "integrity": "sha512-r9sHS+LO9LUoQsiqPo05yTfGRpA3oODc/0AmL0QA1SbeboHKBkCRZIUHkv5w6+GGmWR/G+ZR52eHNLWcgTwIAA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.5.0" + }, + "peerDependencies": { + "@angular/common": "^19.0.0", + "@angular/compiler": "^19.0.0" + } + }, "node_modules/angularx-qrcode": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/angularx-qrcode/-/angularx-qrcode-19.0.0.tgz", diff --git a/package.json b/package.json index 9d0b3e1b9..e3b38309f 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,9 @@ "@ngxs/logger-plugin": "^19.0.0", "@ngxs/store": "^19.0.0", "@primeng/themes": "^19.0.9", + "@sentry/angular": "^10.10.0", "ace-builds": "^1.42.0", + "angular-google-tag-manager": "^1.11.0", "cedar-artifact-viewer": "^0.9.5", "cedar-embeddable-editor": "^1.5.0", "chart.js": "^4.4.9", diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 3edc6c1e5..8e4fc542f 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -2,11 +2,13 @@ import { provideStore, Store } from '@ngxs/store'; import { MockComponents } from 'ng-mocks'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { Subject } from 'rxjs'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { NavigationEnd, Router } from '@angular/router'; +import { OSFConfigService } from '@core/services/osf-config.service'; import { GetCurrentUser, UserState } from '@core/store/user'; import { UserEmailsState } from '@core/store/user-emails'; @@ -14,39 +16,87 @@ import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; import { TranslateServiceMock } from './shared/mocks'; import { AppComponent } from './app.component'; -describe('AppComponent', () => { - let component: AppComponent; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { GoogleTagManagerService } from 'angular-google-tag-manager'; + +describe('Component: App', () => { + let routerEvents$: Subject; + let gtmServiceMock: jest.Mocked; + let osfConfigServiceMock: OSFConfigService; let fixture: ComponentFixture; beforeEach(async () => { + routerEvents$ = new Subject(); + + gtmServiceMock = { + pushTag: jest.fn(), + } as any; + await TestBed.configureTestingModule({ - imports: [AppComponent, ...MockComponents(ToastComponent, FullScreenLoaderComponent)], + imports: [OSFTestingModule, AppComponent, ...MockComponents(ToastComponent, FullScreenLoaderComponent)], providers: [ provideStore([UserState, UserEmailsState]), - provideHttpClient(), - provideHttpClientTesting(), TranslateServiceMock, + { provide: GoogleTagManagerService, useValue: gtmServiceMock }, + { + provide: Router, + useValue: { + events: routerEvents$.asObservable(), + }, + }, + { + provide: OSFConfigService, + useValue: { + has: jest.fn(), + }, + }, ], }).compileComponents(); + osfConfigServiceMock = TestBed.inject(OSFConfigService); fixture = TestBed.createComponent(AppComponent); - component = fixture.componentInstance; - fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); - }); + describe('detect changes', () => { + beforeEach(() => { + fixture.detectChanges(); + }); + + it('should dispatch GetCurrentUser action on initialization', () => { + const store = TestBed.inject(Store); + const dispatchSpy = jest.spyOn(store, 'dispatch'); + store.dispatch(GetCurrentUser); + expect(dispatchSpy).toHaveBeenCalledWith(GetCurrentUser); + }); - it('should dispatch GetCurrentUser action on initialization', () => { - const store = TestBed.inject(Store); - const dispatchSpy = jest.spyOn(store, 'dispatch'); - store.dispatch(GetCurrentUser); - expect(dispatchSpy).toHaveBeenCalledWith(GetCurrentUser); + it('should render router outlet', () => { + const routerOutlet = fixture.debugElement.query(By.css('router-outlet')); + expect(routerOutlet).toBeTruthy(); + }); }); - it('should render router outlet', () => { - const routerOutlet = fixture.debugElement.query(By.css('router-outlet')); - expect(routerOutlet).toBeTruthy(); + describe('Google Tag Manager', () => { + it('should push GTM tag on NavigationEnd with google tag id', () => { + jest.spyOn(osfConfigServiceMock, 'has').mockReturnValue(true); + fixture.detectChanges(); + const event = new NavigationEnd(1, '/previous', '/current'); + + routerEvents$.next(event); + + expect(gtmServiceMock.pushTag).toHaveBeenCalledWith({ + event: 'page', + pageName: '/current', + }); + }); + + it('should not push GTM tag on NavigationEnd with google tag id', () => { + jest.spyOn(osfConfigServiceMock, 'has').mockReturnValue(false); + fixture.detectChanges(); + const event = new NavigationEnd(1, '/previous', '/current'); + + routerEvents$.next(event); + + expect(gtmServiceMock.pushTag).not.toHaveBeenCalled(); + }); }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 983562162..8bae9ec75 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -4,15 +4,21 @@ import { TranslateService } from '@ngx-translate/core'; import { DialogService } from 'primeng/dynamicdialog'; -import { ChangeDetectionStrategy, Component, effect, inject, OnInit } from '@angular/core'; -import { Router, RouterOutlet } from '@angular/router'; +import { filter } from 'rxjs'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; + +import { OSFConfigService } from '@core/services/osf-config.service'; import { GetCurrentUser } from '@core/store/user'; import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails'; import { ConfirmEmailComponent } from '@shared/components'; import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; +import { GoogleTagManagerService } from 'angular-google-tag-manager'; + @Component({ selector: 'osf-root', imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent], @@ -22,9 +28,12 @@ import { FullScreenLoaderComponent, ToastComponent } from './shared/components'; providers: [DialogService], }) export class AppComponent implements OnInit { + private readonly googleTagManagerService = inject(GoogleTagManagerService); + private readonly destroyRef = inject(DestroyRef); private readonly dialogService = inject(DialogService); private readonly router = inject(Router); private readonly translateService = inject(TranslateService); + private readonly osfConfigService = inject(OSFConfigService); private readonly actions = createDispatchMap({ getCurrentUser: GetCurrentUser, getEmails: GetEmails }); @@ -41,6 +50,20 @@ export class AppComponent implements OnInit { ngOnInit(): void { this.actions.getCurrentUser(); this.actions.getEmails(); + + if (this.osfConfigService.has('googleTagManagerId')) { + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((event: NavigationEnd) => { + this.googleTagManagerService.pushTag({ + event: 'page', + pageName: event.urlAfterRedirects, + }); + }); + } } private showEmailDialog() { diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 4f619d1f6..a606ce22b 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -12,13 +12,15 @@ import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; import { STATES } from '@core/constants'; +import { APPLICATION_INITIALIZATION_PROVIDER } from '@core/factory/application.initialization.factory'; import { provideTranslation } from '@core/helpers'; -import { GlobalErrorHandler } from './core/handlers'; import { authInterceptor, errorInterceptor, viewOnlyInterceptor } from './core/interceptors'; import CustomPreset from './core/theme/custom-preset'; import { routes } from './app.routes'; +import * as Sentry from '@sentry/angular'; + export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), @@ -41,6 +43,11 @@ export const appConfig: ApplicationConfig = { importProvidersFrom(TranslateModule.forRoot(provideTranslation())), ConfirmationService, MessageService, - { provide: ErrorHandler, useClass: GlobalErrorHandler }, + + APPLICATION_INITIALIZATION_PROVIDER, + { + provide: ErrorHandler, + useFactory: () => Sentry.createErrorHandler({ showDialog: false }), + }, ], }; diff --git a/src/app/core/factory/application.initialization.factory.spec.ts b/src/app/core/factory/application.initialization.factory.spec.ts new file mode 100644 index 000000000..9e8222020 --- /dev/null +++ b/src/app/core/factory/application.initialization.factory.spec.ts @@ -0,0 +1,79 @@ +import { runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { OSFConfigService } from '@core/services/osf-config.service'; + +import { initializeApplication } from './application.initialization.factory'; + +import * as Sentry from '@sentry/angular'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { GoogleTagManagerConfiguration } from 'angular-google-tag-manager'; + +jest.mock('@sentry/angular', () => ({ + init: jest.fn(), + createErrorHandler: jest.fn(() => 'mockErrorHandler'), +})); + +describe('factory: sentry', () => { + let osfConfigServiceMock: OSFConfigService; + let googleTagManagerConfigurationMock: GoogleTagManagerConfiguration; + const configServiceMock = { + load: jest.fn(), + get: jest.fn(), + } as unknown as jest.Mocked; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [OSFTestingModule], + providers: [ + { + provide: OSFConfigService, + useValue: configServiceMock, + }, + { + provide: GoogleTagManagerConfiguration, + useValue: { + set: jest.fn(), + }, + }, + ], + }).compileComponents(); + + osfConfigServiceMock = TestBed.inject(OSFConfigService); + googleTagManagerConfigurationMock = TestBed.inject(GoogleTagManagerConfiguration); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize Sentry if DSN is provided', async () => { + jest.spyOn(osfConfigServiceMock, 'get').mockReturnValueOnce('google-id').mockReturnValueOnce('https://dsn.url'); + await runInInjectionContext(TestBed, async () => { + await initializeApplication()(); + }); + + expect(Sentry.init).toHaveBeenCalledWith({ + dsn: 'https://dsn.url', + integrations: [], + environment: 'development', + maxBreadcrumbs: 50, + sampleRate: 1, + }); + + expect(googleTagManagerConfigurationMock.set).toHaveBeenCalledWith({ + id: 'google-id', + }); + }); + + it('should initialize Sentry if DSN is missing', async () => { + jest.spyOn(osfConfigServiceMock, 'get').mockReturnValueOnce(null).mockReturnValueOnce(null); + await runInInjectionContext(TestBed, async () => { + await initializeApplication()(); + }); + + expect(Sentry.init).not.toHaveBeenCalled(); + + expect(googleTagManagerConfigurationMock.set).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/core/factory/application.initialization.factory.ts b/src/app/core/factory/application.initialization.factory.ts new file mode 100644 index 000000000..7c17bfcaa --- /dev/null +++ b/src/app/core/factory/application.initialization.factory.ts @@ -0,0 +1,58 @@ +import { inject, provideAppInitializer } from '@angular/core'; + +import { ENVIRONMENT } from '@core/constants/environment.token'; +import { OSFConfigService } from '@core/services/osf-config.service'; + +import * as Sentry from '@sentry/angular'; +import { GoogleTagManagerConfiguration } from 'angular-google-tag-manager'; + +/** + * Asynchronous initializer function that loads the Sentry DSN from the config service + * and initializes Sentry at application bootstrap. + * + * This function is meant to be used with `provideAppInitializer`, which blocks Angular + * bootstrap until the Promise resolves. This avoids race conditions when reading config. + * + * @returns A Promise that resolves once Sentry is initialized (or skipped if no DSN) + */ +export function initializeApplication() { + return async () => { + const configService = inject(OSFConfigService); + const googleTagManagerConfiguration = inject(GoogleTagManagerConfiguration); + const environment = inject(ENVIRONMENT); + + await configService.load(); + + const googleTagManagerId = configService.get('googleTagManagerId'); + if (googleTagManagerId) { + googleTagManagerConfiguration.set({ id: googleTagManagerId }); + } + + const dsn = configService.get('sentryDsn'); + if (dsn) { + // More Options + // https://docs.sentry.io/platforms/javascript/guides/angular/configuration/options/ + Sentry.init({ + dsn, + environment: environment.production ? 'production' : 'development', + maxBreadcrumbs: 50, + sampleRate: 1.0, // error sample rate + integrations: [], + }); + } + }; +} + +/** + * Provides the Sentry initialization logic during Angular's application bootstrap phase. + * + * This uses `provideAppInitializer` to block application startup until Sentry is initialized. + * It ensures that the Sentry DSN (fetched from OSFConfigService) is available and configured + * before any errors are handled or reported by the app. + * + * `initializeSentry` is a function that returns a Promise which resolves after Sentry is fully initialized. + * + * @see https://docs.sentry.io/platforms/javascript/guides/angular/ + * @see Angular's `provideAppInitializer`: https://angular.io/api/core/provideAppInitializer + */ +export const APPLICATION_INITIALIZATION_PROVIDER = provideAppInitializer(initializeApplication()); diff --git a/src/app/core/models/config.model.ts b/src/app/core/models/config.model.ts new file mode 100644 index 000000000..e8e7d152a --- /dev/null +++ b/src/app/core/models/config.model.ts @@ -0,0 +1,38 @@ +export type ConfigModelType = string | number | boolean | null; + +/** + * Interface representing the application-wide configuration model + * loaded from `assets/config/config.json`. + * + * This config supports both strongly typed properties and dynamic keys. + * + */ +export interface ConfigModel { + /** + * The DSN (Data Source Name) used to configure Sentry for error tracking. + * This string is provided by Sentry and uniquely identifies your project. + * + * @example "https://1234567890abcdef.ingest.sentry.io/1234567" + */ + sentryDsn: string; + + /** + * The Google Tag Manager ID used to embed GTM scripts for analytics tracking. + * This ID typically starts with "GTM-". + * + * @example "GTM-ABCDE123" + */ + googleTagManagerId: string; + + /** + * A catch-all for additional configuration keys not explicitly defined. + * Each dynamic property maps to a `ConfigModelType` value. + * + * @example + * { + * "featureToggle": true, + * "apiUrl": "https://api.example.com" + * } + */ + [key: string]: ConfigModelType; +} diff --git a/src/app/core/services/osf-config.service.spec.ts b/src/app/core/services/osf-config.service.spec.ts new file mode 100644 index 000000000..8068ef17f --- /dev/null +++ b/src/app/core/services/osf-config.service.spec.ts @@ -0,0 +1,59 @@ +import { HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { ConfigModel } from '@core/models/config.model'; + +import { OSFConfigService } from './osf-config.service'; + +import { OSFTestingModule } from '@testing/osf.testing.module'; + +describe('Service: Config', () => { + let service: OSFConfigService; + let httpMock: HttpTestingController; + + const mockConfig: ConfigModel = { + apiUrl: 'https://api.example.com', + environment: 'staging', + featureToggle: true, + customKey: 'customValue', + } as any; // Cast to any if index signature isn’t added + + beforeEach(async () => { + jest.clearAllMocks(); + await TestBed.configureTestingModule({ + imports: [OSFTestingModule], + providers: [OSFConfigService], + }).compileComponents(); + + service = TestBed.inject(OSFConfigService); + httpMock = TestBed.inject(HttpTestingController); + }); + + it('should return a value with get()', async () => { + let loadPromise = service.load(); + const request = httpMock.expectOne('/assets/config/config.json'); + request.flush(mockConfig); + await loadPromise; + expect(service.get('apiUrl')).toBe('https://api.example.com'); + expect(service.get('featureToggle')).toBe(true); + loadPromise = service.load(); + await loadPromise; + expect(service.get('nonexistentKey')).toBeNull(); + + expect(httpMock.verify()).toBeUndefined(); + }); + + it('should return a value with ahs()', async () => { + let loadPromise = service.load(); + const request = httpMock.expectOne('/assets/config/config.json'); + request.flush(mockConfig); + await loadPromise; + expect(service.has('apiUrl')).toBeTruthy(); + expect(service.has('featureToggle')).toBeTruthy(); + loadPromise = service.load(); + await loadPromise; + expect(service.has('nonexistentKey')).toBeFalsy(); + + expect(httpMock.verify()).toBeUndefined(); + }); +}); diff --git a/src/app/core/services/osf-config.service.ts b/src/app/core/services/osf-config.service.ts new file mode 100644 index 000000000..872a84a50 --- /dev/null +++ b/src/app/core/services/osf-config.service.ts @@ -0,0 +1,72 @@ +import { lastValueFrom, shareReplay } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { ConfigModel } from '@core/models/config.model'; + +/** + * Service for loading and accessing configuration values + * from the static JSON file at `/assets/config/config.json`. + * + * This service ensures that the configuration is only fetched once + * and made available application-wide via promise-based access. + * + * Consumers must call `get()` or `has()` using `await` to ensure + * that config values are available after loading completes. + */ +@Injectable({ providedIn: 'root' }) +export class OSFConfigService { + /** + * Angular's HttpClient used to fetch the configuration JSON. + * Injected via Angular's dependency injection system. + */ + private http: HttpClient = inject(HttpClient); + + /** + * Stores the loaded configuration object after it is fetched from the server. + * Remains `null` until `load()` is successfully called. + */ + private config: ConfigModel | null = null; + + /** + * Loads the configuration from the JSON file if not already loaded. + * Ensures that only one request is made. + */ + async load(): Promise { + if (!this.config) { + this.config = await lastValueFrom( + this.http.get('/assets/config/config.json').pipe(shareReplay(1)) + ); + } + } + + /** + * Retrieves a configuration value by key after ensuring the config is loaded. + * @param key The key to look up in the config. + * @returns The value of the configuration key. + */ + get(key: T): ConfigModel[T] | null { + return this.config?.[key] || null; + } + + /** + * Checks whether a specific configuration key exists and has a truthy value. + * + * This method inspects the currently loaded configuration object and determines + * if the given key is present and evaluates to a truthy value (e.g., non-null, non-undefined, not false/0/empty string). + * + * @template T - A key of the `ConfigModel` interface. + * @param {T} key - The key to check within the configuration object. + * @returns {boolean} - Returns `true` if the key exists and its value is truthy; otherwise, returns `false`. + * + * @example + * if (configService.has('sentryDsn')) { + * const dsn = configService.get('sentryDsn'); + * Sentry.init({ dsn }); + * } + */ + has(key: T): boolean { + return this.config?.[key] ? true : false; + } +} diff --git a/src/app/shared/helpers/state-error.handler.spec.ts b/src/app/shared/helpers/state-error.handler.spec.ts new file mode 100644 index 000000000..ec4a4ba4d --- /dev/null +++ b/src/app/shared/helpers/state-error.handler.spec.ts @@ -0,0 +1,52 @@ +import { StateContext } from '@ngxs/store'; + +import { firstValueFrom } from 'rxjs'; + +import { handleSectionError } from './state-error.handler'; // adjust path as needed + +import * as Sentry from '@sentry/angular'; + +jest.mock('@sentry/angular'); + +describe('Helper: State Error Handler', () => { + interface TestState { + mySection: { + isLoading: boolean; + isSubmitting: boolean; + error?: string; + otherField?: string; + }; + } + + it('should patch the state and throw the error', async () => { + const patchState = jest.fn(); + const ctx: StateContext = { + getState: () => ({ + mySection: { + isLoading: true, + isSubmitting: true, + otherField: 'someValue', + }, + }), + patchState, + setState: jest.fn(), + dispatch: jest.fn(), + }; + + const error = new Error('Something went wrong'); + + const result$ = handleSectionError(ctx, 'mySection', error); + + expect(patchState).toHaveBeenCalledWith({ + mySection: { + isLoading: false, + isSubmitting: false, + error: 'Something went wrong', + otherField: 'someValue', + }, + }); + + expect(Sentry.captureException).toHaveBeenCalledWith(error); + await expect(firstValueFrom(result$)).rejects.toThrow('Something went wrong'); + }); +}); diff --git a/src/app/shared/helpers/state-error.handler.ts b/src/app/shared/helpers/state-error.handler.ts index 5af177f08..6b2fa3dcf 100644 --- a/src/app/shared/helpers/state-error.handler.ts +++ b/src/app/shared/helpers/state-error.handler.ts @@ -2,7 +2,13 @@ import { StateContext } from '@ngxs/store'; import { throwError } from 'rxjs'; +import * as Sentry from '@sentry/angular'; + export function handleSectionError(ctx: StateContext, section: keyof T, error: Error) { + // Report error to Sentry + Sentry.captureException(error); + + // Patch the state to update loading/submitting flags and set the error message ctx.patchState({ [section]: { ...ctx.getState()[section], @@ -11,5 +17,6 @@ export function handleSectionError(ctx: StateContext, section: keyof T, er error: error.message, }, } as Partial); + // Rethrow the error as an observable return throwError(() => error); } diff --git a/src/assets/config/.git-keep b/src/assets/config/.git-keep new file mode 100644 index 000000000..e69de29bb diff --git a/src/assets/config/template.json b/src/assets/config/template.json new file mode 100644 index 000000000..d407164e3 --- /dev/null +++ b/src/assets/config/template.json @@ -0,0 +1,4 @@ +{ + "sentryDsn": "", + "googleTagManagerId": "" +} diff --git a/src/main.ts b/src/main.ts index dde5c92e3..62b5a3c94 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,4 +8,7 @@ import 'cedar-embeddable-editor'; bootstrapApplication(AppComponent, { providers: [...appConfig.providers], -}).catch((err) => console.error(err)); +}).catch((err) => + // eslint-disable-next-line no-console + console.error(err) +);