diff --git a/src/app/core/components/index.ts b/src/app/core/components/index.ts index 251086f78..0beaef464 100644 --- a/src/app/core/components/index.ts +++ b/src/app/core/components/index.ts @@ -1,6 +1,7 @@ export { BreadcrumbComponent } from './breadcrumb/breadcrumb.component'; export { FooterComponent } from './footer/footer.component'; export { HeaderComponent } from './header/header.component'; +export { MaintenanceBannerComponent } from './maintenance-banner/maintenance-banner.component'; export { PageNotFoundComponent } from './page-not-found/page-not-found.component'; export { RootComponent } from './root/root.component'; export { SidenavComponent } from './sidenav/sidenav.component'; diff --git a/src/app/core/components/maintenance-banner/maintenance-banner.component.html b/src/app/core/components/maintenance-banner/maintenance-banner.component.html new file mode 100644 index 000000000..0e7af0836 --- /dev/null +++ b/src/app/core/components/maintenance-banner/maintenance-banner.component.html @@ -0,0 +1,12 @@ +@if (maintenance && !dismissed) { + + +} diff --git a/src/app/core/components/maintenance-banner/maintenance-banner.component.scss b/src/app/core/components/maintenance-banner/maintenance-banner.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/core/components/maintenance-banner/maintenance-banner.component.spec.ts b/src/app/core/components/maintenance-banner/maintenance-banner.component.spec.ts new file mode 100644 index 000000000..c09e7b84f --- /dev/null +++ b/src/app/core/components/maintenance-banner/maintenance-banner.component.spec.ts @@ -0,0 +1,150 @@ +import { CookieService } from 'ngx-cookie-service'; + +import { MessageModule } from 'primeng/message'; + +import { of } from 'rxjs'; + +import { HttpClient } from '@angular/common/http'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; + +import { MaintenanceBannerComponent } from './maintenance-banner.component'; + +describe('MaintenanceBannerComponent', () => { + let fixture: ComponentFixture; + let httpClient: { get: jest.Mock }; + let cookieService: jest.Mocked; + + beforeEach(async () => { + cookieService = { + check: jest.fn(), + set: jest.fn(), + } as any; + httpClient = { get: jest.fn() } as any; + await TestBed.configureTestingModule({ + imports: [MaintenanceBannerComponent, NoopAnimationsModule, MessageModule], + providers: [ + { provide: CookieService, useValue: cookieService }, + { provide: HttpClient, useValue: httpClient }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(MaintenanceBannerComponent); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render info banner when maintenance data is present', fakeAsync(() => { + cookieService.check.mockReturnValue(false); + const now = new Date(); + const start = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + httpClient.get.mockReturnValueOnce( + of({ + maintenance: { level: 1, message: 'Info message', start, end }, + }) + ); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const banner = fixture.debugElement.query(By.css('p-message')); + expect(banner).toBeTruthy(); + expect(banner.componentInstance.severity).toBe('info'); + expect(banner.nativeElement.textContent).toContain('Info message'); + })); + + it('should render warning banner when level is 2', fakeAsync(() => { + cookieService.check.mockReturnValue(false); + const now = new Date(); + const start = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + httpClient.get.mockReturnValueOnce( + of({ + maintenance: { + level: 2, + message: 'Warning message', + start, + end, + }, + }) + ); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const banner = fixture.debugElement.query(By.css('p-message')); + expect(banner).toBeTruthy(); + expect(banner.componentInstance.severity).toBe('warn'); + expect(banner.nativeElement.textContent).toContain('Warning message'); + })); + + it('should render danger banner when level is 3', fakeAsync(() => { + cookieService.check.mockReturnValue(false); + const now = new Date(); + const start = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + httpClient.get.mockReturnValueOnce( + of({ + maintenance: { + level: 3, + message: 'Danger message', + start, + end, + }, + }) + ); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const banner = fixture.debugElement.query(By.css('p-message')); + expect(banner).toBeTruthy(); + expect(banner.componentInstance.severity).toBe('error'); + expect(banner.nativeElement.textContent).toContain('Danger message'); + })); + + it('should not render banner if cookie is set', fakeAsync(() => { + cookieService.check.mockReturnValue(true); + fixture.detectChanges(); + expect(httpClient.get).not.toHaveBeenCalled(); + fixture.detectChanges(); + const banner = fixture.debugElement.query(By.css('p-message')); + expect(banner).toBeFalsy(); + })); + + it('should not render banner if outside maintenance window', fakeAsync(() => { + cookieService.check.mockReturnValue(false); + httpClient.get.mockReturnValueOnce( + of({ + maintenance: { level: 1, message: 'Old message', start: '2020-01-01T00:00:00Z', end: '2020-01-02T00:00:00Z' }, + }) + ); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const banner = fixture.debugElement.query(By.css('p-message')); + expect(banner).toBeFalsy(); + })); + + it('should dismiss banner when close button is clicked', fakeAsync(() => { + cookieService.check.mockReturnValue(false); + const now = new Date(); + const start = new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString(); + const end = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString(); + httpClient.get.mockReturnValueOnce( + of({ + maintenance: { level: 1, message: 'Dismiss me', start, end }, + }) + ); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + const banner = fixture.debugElement.query(By.css('p-message')); + expect(banner).toBeTruthy(); + banner.triggerEventHandler('onClose', {}); + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('p-message'))).toBeFalsy(); + expect(cookieService.set).toHaveBeenCalled(); + })); +}); diff --git a/src/app/core/components/maintenance-banner/maintenance-banner.component.ts b/src/app/core/components/maintenance-banner/maintenance-banner.component.ts new file mode 100644 index 000000000..cdc78680f --- /dev/null +++ b/src/app/core/components/maintenance-banner/maintenance-banner.component.ts @@ -0,0 +1,48 @@ +import { CookieService } from 'ngx-cookie-service'; + +import { MessageModule } from 'primeng/message'; + +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, OnInit } from '@angular/core'; + +import { Maintenance } from '../../models/maintenance.model'; +import { MaintenanceService } from '../../services/maintenance.service'; + +@Component({ + selector: 'osf-maintenance-banner', + imports: [CommonModule, MessageModule], + templateUrl: './maintenance-banner.component.html', + styleUrls: ['./maintenance-banner.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MaintenanceBannerComponent implements OnInit { + private readonly maintenanceService = inject(MaintenanceService); + private readonly cookies = inject(CookieService); + private readonly cdr = inject(ChangeDetectorRef); + + dismissed = false; + readonly cookieName = 'osf-maintenance-dismissed'; + readonly cookieDurationHours = 24; + + maintenance: Maintenance | null = null; + + ngOnInit(): void { + this.dismissed = this.cookies.check(this.cookieName); + if (!this.dismissed) { + this.fetchMaintenanceStatus(); + } + } + + private fetchMaintenanceStatus(): void { + this.maintenanceService.fetchMaintenanceStatus().subscribe((maintenance) => { + this.maintenance = maintenance; + this.cdr.markForCheck(); + }); + } + + dismiss(): void { + this.cookies.set(this.cookieName, '1', this.cookieDurationHours, '/'); + this.dismissed = true; + this.maintenance = null; + } +} diff --git a/src/app/core/components/root/root.component.html b/src/app/core/components/root/root.component.html index 143bead91..14f0f1997 100644 --- a/src/app/core/components/root/root.component.html +++ b/src/app/core/components/root/root.component.html @@ -3,6 +3,7 @@
+ @@ -13,6 +14,7 @@ } @else {
+ @if (isMedium()) { diff --git a/src/app/core/components/root/root.component.ts b/src/app/core/components/root/root.component.ts index c230d6860..064fde8b6 100644 --- a/src/app/core/components/root/root.component.ts +++ b/src/app/core/components/root/root.component.ts @@ -7,7 +7,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { RouterOutlet } from '@angular/router'; -import { SidenavComponent } from '@core/components'; +import { MaintenanceBannerComponent, SidenavComponent } from '@core/components'; import { BreadcrumbComponent } from '@core/components/breadcrumb/breadcrumb.component'; import { FooterComponent } from '@core/components/footer/footer.component'; import { HeaderComponent } from '@core/components/header/header.component'; @@ -25,6 +25,7 @@ import { IS_MEDIUM, IS_WEB } from '@osf/shared/helpers'; BreadcrumbComponent, RouterOutlet, SidenavComponent, + MaintenanceBannerComponent, TranslatePipe, ], templateUrl: './root.component.html', diff --git a/src/app/core/models/maintenance.model.ts b/src/app/core/models/maintenance.model.ts new file mode 100644 index 000000000..3fc82e1f1 --- /dev/null +++ b/src/app/core/models/maintenance.model.ts @@ -0,0 +1,9 @@ +export interface Maintenance { + level: number; + message: string; + start: string; + end: string; + severity?: MaintenanceSeverity; +} + +export type MaintenanceSeverity = 'info' | 'warn' | 'error'; diff --git a/src/app/core/services/maintenance.service.ts b/src/app/core/services/maintenance.service.ts new file mode 100644 index 000000000..d14e78248 --- /dev/null +++ b/src/app/core/services/maintenance.service.ts @@ -0,0 +1,39 @@ +import { catchError, Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; + +import { Maintenance, MaintenanceSeverity } from '../models/maintenance.model'; + +import { environment } from 'src/environments/environment'; + +@Injectable({ providedIn: 'root' }) +export class MaintenanceService { + private readonly http = inject(HttpClient); + private readonly apiUrl = `${environment.apiDomainUrl}/v2`; + + fetchMaintenanceStatus(): Observable { + return this.http.get<{ maintenance?: Maintenance }>(`${this.apiUrl}/status/`).pipe( + map((data) => { + const maintenance = data.maintenance; + if (maintenance && this.isWithinMaintenanceWindow(maintenance)) { + return { ...maintenance, severity: this.getSeverity(maintenance.level) }; + } + return null; + }), + catchError(() => of(null)) + ); + } + + getSeverity(level: number): MaintenanceSeverity { + const map: Record = { 1: 'info', 2: 'warn', 3: 'error' }; + return map[level] ?? 'info'; + } + + private isWithinMaintenanceWindow(maintenance: Maintenance): boolean { + if (!maintenance.start || !maintenance.end) return false; + const now = new Date(); + return now >= new Date(maintenance.start) && now <= new Date(maintenance.end); + } +}