From d42f972cfc3dac7b99db8bcd0d49cdb2729de8f2 Mon Sep 17 00:00:00 2001 From: cryptalith Date: Wed, 26 Aug 2026 17:40:08 -0600 Subject: [PATCH] PD-5715 --- src/app/app.component.spec.ts | 150 +++++++++++++++++-- src/app/app.component.ts | 57 ++++++- src/app/core/zendesk/zendesk.service.spec.ts | 66 +++++++- src/app/core/zendesk/zendesk.service.ts | 56 +++++-- 4 files changed, 298 insertions(+), 31 deletions(-) diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 2501c697e0..bcbdb3de4a 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -2,36 +2,88 @@ import { TestBed } from '@angular/core/testing' import { RouterTestingModule } from '@angular/router/testing' import { AppComponent } from './app.component' import { HttpClientTestingModule } from '@angular/common/http/testing' -import { - MAT_DIALOG_DATA, - MatDialog, - MatDialogRef, -} from '@angular/material/dialog' +import { MatDialog } from '@angular/material/dialog' import { WINDOW_PROVIDERS } from './cdk/window' -import { FormBuilder } from '@angular/forms' -import { RecordWorksService } from './core/record-works/record-works.service' -import { PlatformInfoService } from './cdk/platform-info' +import { PlatformInfo, PlatformInfoService } from './cdk/platform-info' import { ErrorHandlerService } from './core/error-handler/error-handler.service' import { SnackbarService } from './cdk/snackbar/snackbar.service' import { MatSnackBar } from '@angular/material/snack-bar' import { Overlay } from '@angular/cdk/overlay' import { TitleService } from './core/title-service/title.service' -import { of } from 'rxjs' +import { ZendeskService } from './core/zendesk/zendesk.service' +import { BehaviorSubject, of } from 'rxjs' import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core' +/** + * Mirrors the defaults PlatformInfoService starts with, including the empty + * `currentRoute` it holds until the first NavigationEnd. + */ +const BASE_PLATFORM_INFO: PlatformInfo = { + rtl: false, + ltr: true, + screenDirection: 'ltr', + unsupportedBrowser: false, + desktop: true, + tabletOrHandset: false, + tablet: false, + handset: false, + edge: false, + ie: false, + safary: false, + firefox: false, + columns4: false, + columns8: false, + columns12: true, + hasOauthParameters: false, + social: false, + institutional: false, + queryParameters: {}, + currentRoute: '', + reactivation: false, + reactivationCode: '', + summaryScreen: false, +} + describe('AppComponent', () => { + let platformInfo$: BehaviorSubject + let zendesk: jasmine.SpyObj + + /** Push a new platform state, the way PlatformInfoService's subject does. */ + function emit(overrides: Partial) { + platformInfo$.next({ ...BASE_PLATFORM_INFO, ...overrides }) + } + + function createApp() { + const fixture = TestBed.createComponent(AppComponent) + fixture.detectChanges() + return fixture + } + beforeEach(() => { + platformInfo$ = new BehaviorSubject({ ...BASE_PLATFORM_INFO }) + zendesk = jasmine.createSpyObj('ZendeskService', [ + 'show', + 'hide', + 'open', + 'adaptPluginToPlatform', + 'autofillTicketForm', + ]) + TestBed.configureTestingModule({ imports: [HttpClientTestingModule, RouterTestingModule], declarations: [AppComponent], providers: [ WINDOW_PROVIDERS, - PlatformInfoService, ErrorHandlerService, SnackbarService, MatSnackBar, MatDialog, Overlay, + { + provide: PlatformInfoService, + useValue: { get: () => platformInfo$.asObservable() }, + }, + { provide: ZendeskService, useValue: zendesk }, { provide: TitleService, useValue: { @@ -42,9 +94,87 @@ describe('AppComponent', () => { schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents() }) + it('should create the app', () => { const fixture = TestBed.createComponent(AppComponent) const app = fixture.debugElement.componentInstance expect(app).toBeTruthy() }) + + describe('Zendesk help widget', () => { + it('hides the widget before the router resolves the first route', () => { + createApp() + expect(zendesk.hide).toHaveBeenCalledTimes(1) + expect(zendesk.show).not.toHaveBeenCalled() + }) + + // PD-5715 acceptance criteria + it('never shows the widget on the homepage', () => { + createApp() + emit({ currentRoute: '/' }) + emit({ currentRoute: '/?utm_source=newsletter' }) + expect(zendesk.show).not.toHaveBeenCalled() + expect(zendesk.hide).toHaveBeenCalledTimes(1) + }) + + // PD-5715 acceptance criteria, and the regression guard for 1892786e5: + // an unconditional _zendesk.hide() in the platformInfo subscription fails + // this test. + it('shows the widget on a registry page that is not the homepage', () => { + createApp() + emit({ currentRoute: '/my-orcid' }) + expect(zendesk.show).toHaveBeenCalledTimes(1) + }) + + it('shows the widget on the 404 page, whose copy points at it', () => { + createApp() + emit({ currentRoute: '/this-route-does-not-exist' }) + expect(zendesk.show).toHaveBeenCalledTimes(1) + }) + + it('hides the widget again when navigating back to the homepage', () => { + createApp() + emit({ currentRoute: '/my-orcid' }) + zendesk.hide.calls.reset() + emit({ currentRoute: '/' }) + expect(zendesk.hide).toHaveBeenCalledTimes(1) + }) + + it('does not show the widget while oauth parameters are present', () => { + createApp() + emit({ + currentRoute: '/oauth/authorize', + hasOauthParameters: true, + queryParameters: { client_id: 'APP-0000' }, + }) + expect(zendesk.show).not.toHaveBeenCalled() + }) + + it('does not show the widget on trusted summary routes', () => { + createApp() + emit({ currentRoute: '/0000-0002-1825-0097/summary' }) + expect(zendesk.show).not.toHaveBeenCalled() + }) + + it('acts on visibility transitions only, not on every platform emission', () => { + createApp() + emit({ currentRoute: '/my-orcid' }) + emit({ currentRoute: '/my-orcid', handset: true }) // breakpoint change + emit({ currentRoute: '/my-orcid/works' }) + expect(zendesk.show).toHaveBeenCalledTimes(1) + }) + + it('applies the RTL widget position when it shows the widget', () => { + createApp() + emit({ + currentRoute: '/my-orcid', + rtl: true, + ltr: false, + screenDirection: 'rtl', + }) + expect(zendesk.adaptPluginToPlatform).toHaveBeenCalledWith( + jasmine.objectContaining({ screenDirection: 'rtl' }) + ) + }) + }) }) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 391f44e579..eebc285c20 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -25,7 +25,12 @@ import { OneTrustAccessibilityService } from './core/onetrust/onetrust-accessibi standalone: false, }) export class AppComponent { - currentlyDisplayingZendesk = true + /** + * The last visibility AppComponent requested from ZendeskService - not a + * claim about the widget's actual state. Starts `true` because the snippet + * in index.html renders the launcher by default. + */ + private zendeskDisplayRequested = true headlessMode = false footerlessMode = false spacing: boolean @@ -47,7 +52,7 @@ export class AppComponent { _platformInfo: PlatformInfoService, _router: Router, _googleTagManagerService: GoogleTagManagerService, - _zendesk: ZendeskService, + private _zendesk: ZendeskService, private _userService: UserService, private _errorHandler: ErrorHandlerService, @Inject(WINDOW) private _window: Window, @@ -66,9 +71,7 @@ export class AppComponent { ) this.setPlatformClasses(platformInfo) this.screenDirection = platformInfo.screenDirection - _zendesk.hide() - _zendesk.adaptPluginToPlatform(platformInfo) - this.currentlyDisplayingZendesk = false + this.updateZendeskVisibility(platformInfo) }) ) .subscribe() @@ -108,6 +111,50 @@ export class AppComponent { } }) } + + /** + * Routes that must never surface the Zendesk help widget. + * + * An empty `currentRoute` means PlatformInfoService has not seen a + * NavigationEnd yet. Treat it as hidden, so landing on the homepage hides the + * widget at bootstrap instead of after the router resolves the first route. + */ + private isZendeskFreeRoute(currentRoute: string): boolean { + const path = (currentRoute || '').split('?')[0].split('#')[0] + if (!path) { + return true // the router has not resolved a route yet + } + if (path === '/') { + return true // PD-5715: no help widget on the ORCID homepage + } + if (path.endsWith('/summary')) { + return true // the trusted summary screen hides the widget itself + } + return false + } + + private updateZendeskVisibility(platformInfo: PlatformInfo) { + const shouldDisplay = + !platformInfo.hasOauthParameters && + !this.isZendeskFreeRoute(platformInfo.currentRoute) + + // platformSubject also fires on breakpoint and query parameter changes. + // Act only on an actual transition, so a window resize does not clobber + // out-of-band callers such as OauthErrorComponent's show() or + // TrustedSummaryComponent's hide(). + if (shouldDisplay === this.zendeskDisplayRequested) { + return + } + this.zendeskDisplayRequested = shouldDisplay + + if (shouldDisplay) { + this._zendesk.show() + this._zendesk.adaptPluginToPlatform(platformInfo) + } else { + this._zendesk.hide() + } + } + showHeadlessOnOauthPage(currentRoute: string): boolean { if (currentRoute) { const value = HeadlessOnOauthRoutes.filter( diff --git a/src/app/core/zendesk/zendesk.service.spec.ts b/src/app/core/zendesk/zendesk.service.spec.ts index fb9325b0b0..7fa4d7cb4e 100644 --- a/src/app/core/zendesk/zendesk.service.spec.ts +++ b/src/app/core/zendesk/zendesk.service.spec.ts @@ -1,16 +1,19 @@ import { TestBed } from '@angular/core/testing' +import { WINDOW } from '../../cdk/window' +import { PlatformInfo } from '../../cdk/platform-info' import { ZendeskService } from './zendesk.service' -import { WINDOW_PROVIDERS } from '../../cdk/window' - -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core' describe('ZendeskService', () => { let service: ZendeskService + let fakeWindow: any beforeEach(() => { + fakeWindow = { + location: { href: 'https://orcid.org/my-orcid' }, + } TestBed.configureTestingModule({ - providers: [WINDOW_PROVIDERS], + providers: [{ provide: WINDOW, useValue: fakeWindow }], }) service = TestBed.inject(ZendeskService) }) @@ -18,4 +21,59 @@ describe('ZendeskService', () => { it('should be created', () => { expect(service).toBeTruthy() }) + + /** + * The snippet is a third party script and can be missing entirely. A throw + * here would propagate into AppComponent's platformInfo subscription and + * tear it down for the rest of the session. + */ + it('does not throw when the widget is absent', () => { + expect(() => service.hide()).not.toThrow() + expect(() => service.show()).not.toThrow() + expect(() => service.open()).not.toThrow() + expect(() => + service.adaptPluginToPlatform({ screenDirection: 'rtl' } as PlatformInfo) + ).not.toThrow() + expect(() => service.autofillTicketForm()).not.toThrow() + }) + + it('forwards commands once the widget is present', () => { + fakeWindow.zE = jasmine.createSpy('zE') + + service.hide() + expect(fakeWindow.zE).toHaveBeenCalledWith('webWidget', 'hide') + + service.show() + expect(fakeWindow.zE).toHaveBeenCalledWith('webWidget', 'show') + + service.open() + expect(fakeWindow.zE).toHaveBeenCalledWith('webWidget', 'open') + }) + + it('moves the widget to the left on RTL locales', () => { + fakeWindow.zE = jasmine.createSpy('zE') + + service.adaptPluginToPlatform({ screenDirection: 'ltr' } as PlatformInfo) + expect(fakeWindow.zE).not.toHaveBeenCalled() + + service.adaptPluginToPlatform({ screenDirection: 'rtl' } as PlatformInfo) + expect(fakeWindow.zE).toHaveBeenCalledWith('webWidget', 'updateSettings', { + webWidget: { + position: { horizontal: 'left', vertical: 'bottom' }, + }, + }) + }) + + it('prefills the ticket form without a prior hide or show call', () => { + fakeWindow.zE = jasmine.createSpy('zE') + + service.autofillTicketForm(undefined, 'App Oauth URL with issues') + + expect(fakeWindow.zE).toHaveBeenCalled() + const settings = fakeWindow.zE.calls.mostRecent().args[2] + const subject = settings.webWidget.contactForm.fields.find( + (field) => field.id === 'subject' + ) + expect(subject.prefill['*']).toBe('App Oauth URL with issues') + }) }) diff --git a/src/app/core/zendesk/zendesk.service.ts b/src/app/core/zendesk/zendesk.service.ts index da130bd0be..68f5b60f2c 100644 --- a/src/app/core/zendesk/zendesk.service.ts +++ b/src/app/core/zendesk/zendesk.service.ts @@ -14,27 +14,31 @@ export class ZendeskService { constructor(@Inject(WINDOW) private _window: Window) {} hide() { - this.zE = (this._window as any).zE - this.zE('webWidget', 'hide') + this.run('hide') } show() { - this.zE = (this._window as any).zE - this.zE('webWidget', 'show') + this.run('show') } open() { - this.zE('webWidget', 'open') + this.run('open') } adaptPluginToPlatform(platform: PlatformInfo) { - if (platform.screenDirection === 'rtl') { - this.zE('webWidget', 'updateSettings', { - webWidget: { - position: { horizontal: 'left', vertical: 'bottom' }, - }, - }) + if (platform.screenDirection !== 'rtl') { + return + } + const zE = this.widget() + if (!zE) { + return } + this.zE = zE + zE('webWidget', 'updateSettings', { + webWidget: { + position: { horizontal: 'left', vertical: 'bottom' }, + }, + }) } /** @@ -46,13 +50,19 @@ export class ZendeskService { * @param errorCode error code to add more context for the support staff */ autofillTicketForm(user?: UserSession, subject?: string, errorCode?: string) { + const zE = this.widget() + if (!zE) { + return + } + this.zE = zE + let uri = '' const uriMatch = this._window.location.href.match(REDIRECT_URI_REGEXP) if (uriMatch && uriMatch[0] && uriMatch[0].indexOf('redirect_uri=') === 0) { uri = decodeURIComponent(uriMatch[0].split('redirect_uri=')[1]) } - this.zE('webWidget', 'updateSettings', { + zE('webWidget', 'updateSettings', { webWidget: { helpCenter: { suppress: true, @@ -106,4 +116,26 @@ Leave your comments above if required. }, }) } + + /** + * Always re-read the widget from `window`. + * + * The Zendesk snippet is a third party script: it can be missing entirely + * (ad blocker, offline, blocked domain) and it lands after this service is + * constructed. Every command therefore has to tolerate its absence - a + * throw here would propagate into AppComponent's platformInfo subscription + * and tear it down for the rest of the session. + */ + private widget(): ZendeskWidget | undefined { + return (this._window as any)?.zE + } + + private run(command: 'hide' | 'show' | 'open'): void { + const zE = this.widget() + if (!zE) { + return + } + this.zE = zE + zE('webWidget', command) + } }