Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
<router-outlet />
<osf-toast></osf-toast>
<osf-full-screen-loader></osf-full-screen-loader>

@if (isMaintenanceMode()) {
<section class="maintenance-overlay font-bold text-xl flex flex-column align-items-center justify-content-center">
<p>{{ 'maintenance.title' | translate }}</p>
<p>{{ 'maintenance.message' | translate }}</p>
</section>
}
6 changes: 6 additions & 0 deletions src/app/app.component.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.maintenance-overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: var(--white);
}
24 changes: 24 additions & 0 deletions src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NavigationEnd, ResolveStart, Router } from '@angular/router';

import { ENVIRONMENT } from '@core/provider/environment.provider';
import { MaintenanceModeService } from '@core/services/maintenance-mode.service';
import { GetCurrentUser } from '@core/store/user';
import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails';
import { AccountEmailModel } from '@osf/shared/models/emails/account-email.model';
Expand All @@ -17,6 +18,10 @@ import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'
import { provideOSFCore } from '@testing/osf.testing.provider';
import { CustomDialogServiceMockBuilder } from '@testing/providers/custom-dialog-provider.mock';
import { LoaderServiceMock, provideLoaderServiceMock } from '@testing/providers/loader-service.mock';
import {
MaintenanceModeServiceMock,
MaintenanceModeServiceMockType,
} from '@testing/providers/maintenance-mode.service.mock';
import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock';
import { BaseSetupOverrides, mergeSignalOverrides, provideMockStore } from '@testing/providers/store-provider.mock';

Expand All @@ -36,6 +41,7 @@ describe('AppComponent', () => {
let loaderServiceMock: LoaderServiceMock;
let customDialogServiceMock: ReturnType<CustomDialogServiceMockBuilder['build']>;
let gtmServiceMock: { pushTag: Mock };
let maintenanceModeServiceMock: MaintenanceModeServiceMockType;

const unverifiedEmail: AccountEmailModel = {
id: 'email-1',
Expand All @@ -50,6 +56,7 @@ describe('AppComponent', () => {
isBrowser?: boolean;
unverifiedEmails?: AccountEmailModel[];
googleTagManagerId?: string;
isMaintenanceMode?: boolean;
}

function setup(overrides: SetupOverrides = {}) {
Expand All @@ -58,6 +65,7 @@ describe('AppComponent', () => {
loaderServiceMock = new LoaderServiceMock();
customDialogServiceMock = CustomDialogServiceMockBuilder.create().withDefaultOpen().build();
gtmServiceMock = { pushTag: vi.fn() };
maintenanceModeServiceMock = MaintenanceModeServiceMock.simple(overrides.isMaintenanceMode ?? false);

TestBed.configureTestingModule({
imports: [AppComponent, ...MockComponents(ToastComponent, FullScreenLoaderComponent)],
Expand All @@ -67,6 +75,7 @@ describe('AppComponent', () => {
MockProvider(Router, routerMock),
MockProvider(CustomDialogService, customDialogServiceMock),
MockProvider(GoogleTagManagerService, gtmServiceMock),
MockProvider(MaintenanceModeService, maintenanceModeServiceMock),
MockProvider(PLATFORM_ID, overrides.isBrowser === false ? 'server' : 'browser'),
provideMockStore({
signals: mergeSignalOverrides(
Expand Down Expand Up @@ -149,6 +158,21 @@ describe('AppComponent', () => {
});
});

it('should probe maintenance mode once on init in browser', () => {
setup();
expect(maintenanceModeServiceMock.checkOnce).toHaveBeenCalledTimes(1);
});

it('should render the maintenance overlay when maintenance mode is active', () => {
setup({ isMaintenanceMode: true });
expect(fixture.nativeElement.querySelector('.maintenance-overlay')).toBeTruthy();
});

it('should not render the maintenance overlay when maintenance mode is inactive', () => {
setup();
expect(fixture.nativeElement.querySelector('.maintenance-overlay')).toBeFalsy();
});

it('should not subscribe to router events on server', () => {
setup({ isBrowser: false });
routerBuilder.emit(new ResolveStart(1, '/next', '/next', {} as any));
Expand Down
10 changes: 9 additions & 1 deletion src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createDispatchMap, select } from '@ngxs/store';

import { TranslatePipe } from '@ngx-translate/core';

import { switchMap, timer } from 'rxjs';

import { isPlatformBrowser } from '@angular/common';
Expand All @@ -8,6 +10,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationCancel, NavigationEnd, NavigationError, ResolveStart, Router, RouterOutlet } from '@angular/router';

import { ENVIRONMENT } from '@core/provider/environment.provider';
import { MaintenanceModeService } from '@core/services/maintenance-mode.service';
import { GetCurrentUser } from '@core/store/user';
import { GetEmails, UserEmailsSelectors } from '@core/store/user-emails';

Expand All @@ -21,7 +24,7 @@ import { GoogleTagManagerService } from 'angular-google-tag-manager';

@Component({
selector: 'osf-root',
imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent],
imports: [RouterOutlet, ToastComponent, FullScreenLoaderComponent, TranslatePipe],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
Expand All @@ -36,8 +39,10 @@ export class AppComponent implements OnInit {
private readonly platformId = inject(PLATFORM_ID);
private readonly isBrowser = isPlatformBrowser(this.platformId);
private readonly loaderService = inject(LoaderService);
private readonly maintenanceModeService = inject(MaintenanceModeService);

unverifiedEmails = select(UserEmailsSelectors.getUnverifiedEmails);
isMaintenanceMode = this.maintenanceModeService.isActive;

constructor() {
effect(() => {
Expand All @@ -57,6 +62,9 @@ export class AppComponent implements OnInit {
.subscribe();

if (this.isBrowser) {
// `GetCurrentUser` short-circuits on a cached user, so it cannot be relied on to surface an outage.
this.maintenanceModeService.checkOnce();

this.router.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
if (event instanceof ResolveStart) {
this.loaderService.show();
Expand Down
7 changes: 0 additions & 7 deletions src/app/core/components/layout/layout.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,6 @@

<osf-footer></osf-footer>
</div>

@if (isMaintenanceMode()) {
<section class="maintenance-overlay font-bold text-xl flex flex-column align-items-center justify-content-center">
<p>{{ 'maintenance.title' | translate }}</p>
<p>{{ 'maintenance.message' | translate }}</p>
</section>
}
</main>

<p-confirm-dialog
Expand Down
7 changes: 0 additions & 7 deletions src/app/core/components/layout/layout.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,3 @@
}
}
}

.maintenance-overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: var(--white);
}
4 changes: 0 additions & 4 deletions src/app/core/components/layout/layout.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { RouterOutlet } from '@angular/router';

import { MaintenanceModeService } from '@core/services/maintenance-mode.service';
import { ScrollTopOnRouteChangeDirective } from '@osf/shared/directives/scroll-top.directive';
import { IS_MEDIUM, IS_WEB } from '@osf/shared/helpers/breakpoints.tokens';

Expand Down Expand Up @@ -36,9 +35,6 @@ import { TopnavComponent } from '../topnav/topnav.component';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LayoutComponent {
private readonly maintenanceModeService = inject(MaintenanceModeService);

isWeb = toSignal(inject(IS_WEB));
isMedium = toSignal(inject(IS_MEDIUM));
isMaintenanceMode = this.maintenanceModeService.isActive;
}
12 changes: 12 additions & 0 deletions src/app/core/services/maintenance-mode.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ export class MaintenanceModeService implements OnDestroy {

readonly isActive = this._isActive.asReadonly();

/**
* Check for maintenance mode upon application startup.
* If the application is in maintenance mode, activate the service and start polling for when maintenance mode ends.
*/
checkOnce(): void {
this.checkMaintenanceStatus().subscribe((isMaintenance) => {
if (isMaintenance) {
this.activate();
}
});
}

activate(): void {
this._isActive.set(true);
if (this.pollingSubscription) {
Expand Down
6 changes: 4 additions & 2 deletions src/testing/providers/maintenance-mode.service.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@ import { MaintenanceModeService } from '@core/services/maintenance-mode.service'
export type MaintenanceModeServiceMockType = Partial<MaintenanceModeService> & {
activate: Mock<() => void>;
deactivate: Mock<() => void>;
checkOnce: Mock<() => void>;
isActive: Signal<boolean>;
};

export const MaintenanceModeServiceMock = {
simple() {
simple(isActive = false) {
return {
activate: vi.fn(),
deactivate: vi.fn(),
isActive: signal(false).asReadonly(),
checkOnce: vi.fn(),
isActive: signal(isActive).asReadonly(),
} as MaintenanceModeServiceMockType;
},
};
Loading