Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/@types/global.d.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,51 @@
import type gapi from 'gapi-script'; // or just `import gapi from 'gapi-script';`

/**
* Extends the global `Window` interface to include additional properties used by the application,
* such as Google APIs (`gapi`, `google.picker`) and the `dataLayer` for analytics or GTM integration.
*/
declare global {
interface Window {
/**
* Represents the Google API client library (`gapi`) attached to the global window.
* Used for OAuth, Picker API, Drive API, etc.
*
* @see https://developers.google.com/api-client-library/javascript/
*/
gapi: typeof gapi;

/**
* Contains Google-specific UI services attached to the global window,
* such as the `google.picker` API.
*
* @see https://developers.google.com/picker/docs/
*/
google: {
/**
* Reference to the Google Picker API used for file selection and Drive integration.
*/
picker: typeof google.picker;
};

/**
* Global analytics `dataLayer` object used by Google Tag Manager (GTM).
* Can store custom application metadata for tracking and event push.
*
* @property resourceType - The type of resource currently being viewed (e.g., 'project', 'file', etc.)
* @property loggedIn - Indicates whether the user is currently authenticated.
*/
dataLayer: {
/**
* The type of content or context being viewed (e.g., "project", "node", etc.).
* Optional — may be undefined depending on when or where GTM initializes.
*/
resourceType: string | undefined;

/**
* Indicates if the current user is authenticated.
* Used for segmenting analytics based on login state.
*/
loggedIn: boolean;
};
}
}
14 changes: 13 additions & 1 deletion src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ import { ConfirmationService, MessageService } from 'primeng/api';
import { providePrimeNG } from 'primeng/config';

import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { ApplicationConfig, ErrorHandler, importProvidersFrom, provideZoneChangeDetection } from '@angular/core';
import {
ApplicationConfig,
ErrorHandler,
importProvidersFrom,
PLATFORM_ID,
provideZoneChangeDetection,
} from '@angular/core';
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 { WINDOW, windowFactory } from '@core/factory/window.factory';
import { provideTranslation } from '@core/helpers';

import { authInterceptor, errorInterceptor, viewOnlyInterceptor } from './core/interceptors';
Expand Down Expand Up @@ -49,5 +56,10 @@ export const appConfig: ApplicationConfig = {
provide: ErrorHandler,
useFactory: () => Sentry.createErrorHandler({ showDialog: false }),
},
{
provide: WINDOW,
useFactory: windowFactory,
deps: [PLATFORM_ID],
},
],
};
32 changes: 32 additions & 0 deletions src/app/core/factory/window.factory.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// src/app/window.spec.ts
import { isPlatformBrowser } from '@angular/common';

import { windowFactory } from './window.factory';

jest.mock('@angular/common', () => ({
isPlatformBrowser: jest.fn(),
}));

describe('windowFactory', () => {
const mockWindow = globalThis.window;

beforeEach(() => {
jest.clearAllMocks();
});

it('should return window object if platform is browser', () => {
(isPlatformBrowser as jest.Mock).mockReturnValue(true);

const result = windowFactory('browser');
expect(isPlatformBrowser).toHaveBeenCalledWith('browser');
expect(result).toBe(mockWindow);
});

it('should return empty object if platform is not browser', () => {
(isPlatformBrowser as jest.Mock).mockReturnValue(false);

const result = windowFactory('server');
expect(isPlatformBrowser).toHaveBeenCalledWith('server');
expect(result).toEqual({});
});
});
26 changes: 26 additions & 0 deletions src/app/core/factory/window.factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { isPlatformBrowser } from '@angular/common';
import { InjectionToken } from '@angular/core';

export const WINDOW = new InjectionToken<Window>('Global Window Object');

/**
* A factory function to provide the global `window` object in Angular.
*
* This is useful for making Angular applications **Universal-compatible** (i.e., supporting server-side rendering).
* It conditionally returns the real `window` only when the code is running in the **browser**, not on the server.
*
* @param platformId - The Angular platform ID token (injected by Angular) that helps detect the execution environment.
* @returns The actual `window` object if running in the browser, otherwise a mock object `{}` for SSR environments.
*
* @see https://angular.io/api/core/PLATFORM_ID
* @see https://angular.io/guide/universal
*/
export function windowFactory(platformId: string): Window | object {
// Check if we're running in the browser (vs server-side)
if (isPlatformBrowser(platformId)) {
return window;
}

// Return an empty object as a safe fallback during server-side rendering
return {};
}
65 changes: 65 additions & 0 deletions src/app/core/services/help-scout.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { Store } from '@ngxs/store';

import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';

import { WINDOW } from '@core/factory/window.factory';
import { UserSelectors } from '@core/store/user/user.selectors';

import { HelpScoutService } from './help-scout.service';

describe('HelpScoutService', () => {
let storeMock: Partial<Store>;
let service: HelpScoutService;
let mockWindow: any;
const authSignal = signal(false);

beforeEach(() => {
mockWindow = {
dataLayer: {},
};

storeMock = {
selectSignal: jest.fn().mockImplementation((selector) => {
if (selector === UserSelectors.isAuthenticated) {
return authSignal;
}
return signal(null); // fallback
}),
};

TestBed.configureTestingModule({
providers: [{ provide: WINDOW, useValue: mockWindow }, HelpScoutService, { provide: Store, useValue: storeMock }],
});

service = TestBed.inject(HelpScoutService);
});

it('should initialize dataLayer with default values', () => {
expect(mockWindow.dataLayer).toEqual({
loggedIn: false,
resourceType: undefined,
});
});

it('should set the resourceType', () => {
service.setResourceType('project');
expect(mockWindow.dataLayer.resourceType).toBe('project');
});

it('should unset the resourceType', () => {
service.setResourceType('node');
service.unsetResourceType();
expect(mockWindow.dataLayer.resourceType).toBeUndefined();
});

it('should set loggedIn to true or false', () => {
authSignal.set(true);
TestBed.flushEffects();
expect(mockWindow.dataLayer.loggedIn).toBeTruthy();

authSignal.set(false);
TestBed.flushEffects();
expect(mockWindow.dataLayer.loggedIn).toBeFalsy();
});
});
79 changes: 79 additions & 0 deletions src/app/core/services/help-scout.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Store } from '@ngxs/store';

import { effect, inject, Injectable } from '@angular/core';

import { WINDOW } from '@core/factory/window.factory';
import { UserSelectors } from '@osf/core/store/user';

/**
* HelpScoutService manages GTM-compatible `dataLayer` state
* related to user authentication and resource type.
*
* This service ensures that specific fields in the global
* `window.dataLayer` object are set correctly for downstream
* tools like Google Tag Manager or HelpScout integrations.
*/
@Injectable({
providedIn: 'root',
})
export class HelpScoutService {
/**
* Reference to the global window object, injected via a factory.
* Used to access and manipulate `dataLayer` for tracking.
*/
private window = inject(WINDOW);

/**
* Angular Store instance used to access application state via NgRx.
* Injected using Angular's `inject()` function.
*
* @private
* @type {Store}
*/
private store = inject(Store);

/**
* Signal that represents the current authentication state of the user.
* Derived from the NgRx selector `UserSelectors.isAuthenticated`.
*
* Can be used reactively in effects or template bindings to update UI or behavior
* based on whether the user is logged in.
*
* @private
* @type {Signal<boolean>}
*/
private isAuthenticated = this.store.selectSignal(UserSelectors.isAuthenticated);

/**
* Initializes the `dataLayer` with default values.
*
* - `loggedIn`: false
* - `resourceType`: undefined
*/
constructor() {
this.window.dataLayer = {
loggedIn: false,
resourceType: undefined,
};

effect(() => {
this.window.dataLayer.loggedIn = this.isAuthenticated();
});
}

/**
* Sets the current resource type in the `dataLayer`.
*
* @param resourceType - The name of the resource (e.g., 'project', 'node')
*/
setResourceType(resourceType: string): void {
this.window.dataLayer.resourceType = resourceType;
}

/**
* Clears the `resourceType` from the `dataLayer`, setting it to `undefined`.
*/
unsetResourceType(): void {
this.window.dataLayer.resourceType = undefined;
}
}
29 changes: 23 additions & 6 deletions src/app/features/preprints/preprints.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,48 @@ import { MockProvider } from 'ng-mocks';
import { BehaviorSubject } from 'rxjs';

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';

import { HelpScoutService } from '@core/services/help-scout.service';
import { IS_WEB } from '@osf/shared/helpers';

import { PreprintsComponent } from './preprints.component';

describe('PreprintsComponent', () => {
import { OSFTestingModule } from '@testing/osf.testing.module';

describe('Component: Preprint', () => {
let component: PreprintsComponent;
let fixture: ComponentFixture<PreprintsComponent>;
let isWebSubject: BehaviorSubject<boolean>;
let helpScountService: HelpScoutService;

beforeEach(async () => {
isWebSubject = new BehaviorSubject<boolean>(true);

await TestBed.configureTestingModule({
imports: [PreprintsComponent],
providers: [provideRouter([]), MockProvider(IS_WEB, isWebSubject)],
imports: [PreprintsComponent, OSFTestingModule],
providers: [
MockProvider(IS_WEB, isWebSubject),
{
provide: HelpScoutService,
useValue: {
setResourceType: jest.fn(),
unsetResourceType: jest.fn(),
},
},
],
}).compileComponents();

helpScountService = TestBed.inject(HelpScoutService);
fixture = TestBed.createComponent(PreprintsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
it('should have a default value', () => {
expect(component.isDesktop()).toBeTruthy();
});

it('should called the helpScoutService', () => {
expect(helpScountService.setResourceType).toHaveBeenCalledWith('preprint');
});
});
14 changes: 12 additions & 2 deletions src/app/features/preprints/preprints.component.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ChangeDetectionStrategy, Component, HostBinding, inject } from '@angular/core';
import { ChangeDetectionStrategy, Component, HostBinding, inject, OnDestroy } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { RouterOutlet } from '@angular/router';

import { HelpScoutService } from '@core/services/help-scout.service';
import { IS_WEB } from '@osf/shared/helpers';

@Component({
Expand All @@ -11,7 +12,16 @@ import { IS_WEB } from '@osf/shared/helpers';
styleUrl: './preprints.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PreprintsComponent {
export class PreprintsComponent implements OnDestroy {
private readonly helpScoutService = inject(HelpScoutService);
readonly isDesktop = toSignal(inject(IS_WEB));
@HostBinding('class') classes = 'flex flex-1 flex-column w-full';

constructor() {
this.helpScoutService.setResourceType('preprint');
}

ngOnDestroy(): void {
this.helpScoutService.unsetResourceType();
}
}
Loading