Skip to content
Draft
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
5 changes: 5 additions & 0 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,11 @@ export const routes: Routes = [
import('./core/components/page-not-found/page-not-found.component').then((mod) => mod.PageNotFoundComponent),
data: { skipBreadcrumbs: true },
},
{
path: ':id/files/:fileGuid/preview',
loadComponent: () =>
import('./features/files/pages/file-preview/file-preview.component').then((m) => m.FilePreviewComponent),
},
{
path: 'spam-content',
loadComponent: () =>
Expand Down
7 changes: 7 additions & 0 deletions src/app/core/components/layout/layout.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@

<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: 7 additions & 0 deletions src/app/core/components/layout/layout.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,10 @@
}
}
}

.maintenance-overlay {
position: fixed;
inset: 0;
z-index: 2000;
background: var(--white);
}
5 changes: 3 additions & 2 deletions src/app/core/components/layout/layout.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { MockComponents, MockProvider } from 'ng-mocks';

import { ConfirmationService } from 'primeng/api';
import { ConfirmDialog } from 'primeng/confirmdialog';

import { BehaviorSubject } from 'rxjs';

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

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

import { provideOSFCore } from '@testing/osf.testing.provider';
import { MaintenanceModeServiceMock } from '@testing/providers/maintenance-mode.service.mock';

import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component';
import { FooterComponent } from '../footer/footer.component';
Expand Down Expand Up @@ -47,7 +48,7 @@ describe('LayoutComponent', () => {
provideOSFCore(),
MockProvider(IS_WEB, isWebSubject),
MockProvider(IS_MEDIUM, isMediumSubject),
MockProvider(ConfirmationService),
MockProvider(MaintenanceModeService, MaintenanceModeServiceMock.simple()),
],
});

Expand Down
4 changes: 4 additions & 0 deletions src/app/core/components/layout/layout.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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 @@ -35,6 +36,9 @@ 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
styleClass="w-full"
icon="pi pi-info-circle"
[severity]="maintenance()?.severity"
[text]="maintenance()?.message"
[closable]="true"
(onClose)="dismiss()"
>
{{ maintenance()?.message }}
</p-message>
}
25 changes: 25 additions & 0 deletions src/app/core/interceptors/error.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,17 @@ import { Router } from '@angular/router';

import { SENTRY_TOKEN } from '@core/provider/sentry.provider';
import { AuthService } from '@core/services/auth.service';
import { MaintenanceModeService } from '@core/services/maintenance-mode.service';
import { ToastService } from '@osf/shared/services/toast.service';
import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';

import { provideOSFCore } from '@testing/osf.testing.provider';
import { AuthServiceMock, AuthServiceMockType } from '@testing/providers/auth-service.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 { SentryMock, SentryMockType } from '@testing/providers/sentry-provider.mock';
import { ToastServiceMock, ToastServiceMockType } from '@testing/providers/toast-provider.mock';
Expand All @@ -28,6 +33,7 @@ describe('errorInterceptor', () => {
let toastServiceMock: ToastServiceMockType;
let loaderServiceMock: LoaderServiceMock;
let authServiceMock: AuthServiceMockType;
let maintenanceModeServiceMock: MaintenanceModeServiceMockType;
let viewOnlyHelperMock: ViewOnlyLinkHelperMockType;
let sentryMock: SentryMockType;

Expand All @@ -36,6 +42,7 @@ describe('errorInterceptor', () => {
toastServiceMock = ToastServiceMock.simple();
loaderServiceMock = new LoaderServiceMock();
authServiceMock = AuthServiceMock.simple();
maintenanceModeServiceMock = MaintenanceModeServiceMock.simple();
viewOnlyHelperMock = ViewOnlyLinkHelperMock.simple(viewOnly);
sentryMock = SentryMock.simple();

Expand All @@ -46,6 +53,7 @@ describe('errorInterceptor', () => {
MockProvider(Router, router),
MockProvider(ToastService, toastServiceMock),
MockProvider(AuthService, authServiceMock),
MockProvider(MaintenanceModeService, maintenanceModeServiceMock),
MockProvider(ViewOnlyLinkHelperService, viewOnlyHelperMock),
MockProvider(PLATFORM_ID, platformId),
{ provide: SENTRY_TOKEN, useValue: sentryMock },
Expand Down Expand Up @@ -156,4 +164,21 @@ describe('errorInterceptor', () => {
expect(loaderServiceMock.hide).toHaveBeenCalled();
expect(toastServiceMock.showError).not.toHaveBeenCalled();
});

it('should activate maintenance mode on 503 maintenance response', async () => {
setup('browser', false);
const request = createRequest('/api/v2/');
const error = new HttpErrorResponse({
status: 503,
error: { meta: { maintenance_mode: true } },
url: request.url,
});

const caught = await runInterceptor(request, error);

expect(caught?.status).toBe(503);
expect(maintenanceModeServiceMock.activate).toHaveBeenCalled();
expect(loaderServiceMock.hide).toHaveBeenCalled();
expect(toastServiceMock.showError).not.toHaveBeenCalled();
});
});
14 changes: 14 additions & 0 deletions src/app/core/interceptors/error.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { inject, PLATFORM_ID } from '@angular/core';
import { Router } from '@angular/router';

import { ERROR_MESSAGES } from '@core/constants/error-messages';
import { MaintenanceResponse } from '@core/models/maintenance-response.model';
import { SENTRY_TOKEN } from '@core/provider/sentry.provider';
import { AuthService } from '@core/services/auth.service';
import { MaintenanceModeService } from '@core/services/maintenance-mode.service';
import { LoaderService } from '@osf/shared/services/loader.service';
import { ToastService } from '@osf/shared/services/toast.service';
import { ViewOnlyLinkHelperService } from '@osf/shared/services/view-only-link-helper.service';
Expand All @@ -20,6 +22,7 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const loaderService = inject(LoaderService);
const router = inject(Router);
const authService = inject(AuthService);
const maintenanceModeService = inject(MaintenanceModeService);
const sentry = inject(SENTRY_TOKEN);
const platformId = inject(PLATFORM_ID);
const viewOnlyHelper = inject(ViewOnlyLinkHelperService);
Expand All @@ -43,6 +46,17 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => {
}

const serverErrorRegex = /5\d{2}/;
const maintenanceResponse = error.error as MaintenanceResponse | null;

const maintenanceMode = error.status === 503 && maintenanceResponse?.meta?.maintenance_mode === true;

if (maintenanceMode) {
loaderService.hide();
if (isPlatformBrowser(platformId)) {
maintenanceModeService.activate();
}
return throwError(() => error);
}

if (serverErrorRegex.test(error.status.toString())) {
errorMessage = error.error.message || 'common.errorMessages.serverError';
Expand Down
5 changes: 5 additions & 0 deletions src/app/core/models/maintenance-response.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface MaintenanceResponse {
meta?: {
maintenance_mode?: boolean;
};
}
66 changes: 66 additions & 0 deletions src/app/core/services/maintenance-mode.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { catchError, map, Observable, of, Subscription, switchMap, timer } from 'rxjs';

import { HttpClient, HttpContext } from '@angular/common/http';
import { inject, Injectable, OnDestroy, signal } from '@angular/core';

import { MaintenanceResponse } from '@core/models/maintenance-response.model';
import { ENVIRONMENT } from '@core/provider/environment.provider';

import { BYPASS_ERROR_INTERCEPTOR } from '../interceptors/error-interceptor.tokens';

@Injectable({
providedIn: 'root',
})
export class MaintenanceModeService implements OnDestroy {
private readonly http = inject(HttpClient);
private readonly environment = inject(ENVIRONMENT);

private readonly POLL_INTERVAL_MS = 5 * 60 * 1_000;
private readonly _isActive = signal(false);
private readonly bypassContext = new HttpContext().set(BYPASS_ERROR_INTERCEPTOR, true);

private pollingSubscription: Subscription | null = null;

readonly isActive = this._isActive.asReadonly();

activate(): void {
this._isActive.set(true);
if (this.pollingSubscription) {
return;
}
this.startPolling();
}

deactivate(): void {
this._isActive.set(false);
this.stopPolling();
}

ngOnDestroy(): void {
this.stopPolling();
}

private startPolling(): void {
this.pollingSubscription = timer(0, this.POLL_INTERVAL_MS)
.pipe(switchMap(() => this.checkMaintenanceStatus()))
.subscribe((isMaintenance) => {
if (!isMaintenance) {
this.deactivate();
}
});
}

private stopPolling(): void {
this.pollingSubscription?.unsubscribe();
this.pollingSubscription = null;
}

private checkMaintenanceStatus(): Observable<boolean> {
return this.http
.get<MaintenanceResponse>(`${this.environment.apiDomainUrl}/v2/`, { context: this.bypassContext })
.pipe(
map((response) => response.meta?.maintenance_mode === true),
catchError(() => of(true))
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ <h3>{{ 'collections.addToCollection.projectContributors' | translate }}</h3>
[(contributors)]="projectContributors"
[tableParams]="tableParams()"
[isLoading]="isContributorsLoading()"
[showLoadMore]="hasMoreContributors()"
[isLoadingMore]="isLoadingMore()"
(remove)="handleRemoveContributor($event)"
(loadMore)="loadMoreContributors()"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,16 @@ export class ProjectContributorsStepComponent {
private readonly customConfirmationService = inject(CustomConfirmationService);
private readonly router = inject(Router);

readonly currentUser = select(UserSelectors.getCurrentUser);
readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading);
readonly contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount);
readonly selectedProject = select(ProjectsSelectors.getSelectedProject);
readonly currentUser = select(UserSelectors.getCurrentUser);
isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore);
readonly isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore);
readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors);
readonly pageSize = select(ContributorsSelectors.getContributorsPageSize);

private initialContributors = select(ContributorsSelectors.getContributors);
readonly projectContributors = signal<ContributorModel[]>([]);
pageSize = select(ContributorsSelectors.getContributorsPageSize);

readonly tableParams = computed<TableParameters>(() => ({
...DEFAULT_TABLE_PARAMS,
Expand Down
1 change: 1 addition & 0 deletions src/app/features/contributors/contributors.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ <h1 class="py-5 px-3 md:px-5 xl:px-4">{{ 'navigation.contributors' | translate }
[tableParams]="tableParams()"
[hasAdminAccess]="hasAdminAccess()"
[currentUserId]="currentUser()?.id"
[showLoadMore]="hasMoreContributors()"
[showCurator]="true"
[showInfo]="true"
[resourceType]="resourceType()"
Expand Down
5 changes: 3 additions & 2 deletions src/app/features/contributors/contributors.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,12 @@ export class ContributorsComponent implements OnInit, OnDestroy {
readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading);
readonly contributorsTotalCount = select(ContributorsSelectors.getContributorsTotalCount);
readonly isViewOnlyLinksLoading = select(ViewOnlyLinkSelectors.isViewOnlyLinksLoading);
readonly isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore);
readonly pageSize = select(ContributorsSelectors.getContributorsPageSize);
readonly hasMoreContributors = select(ContributorsSelectors.hasMoreContributors);
readonly hasAdminAccess = select(CurrentResourceSelectors.hasResourceAdminAccess);
readonly resourceAccessRequestEnabled = select(CurrentResourceSelectors.resourceAccessRequestEnabled);
readonly currentUser = select(UserSelectors.getCurrentUser);
pageSize = select(ContributorsSelectors.getContributorsPageSize);
isLoadingMore = select(ContributorsSelectors.isContributorsLoadingMore);

readonly tableParams = computed<TableParameters>(() => ({
...DEFAULT_TABLE_PARAMS,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<osf-sub-header [isLoading]="isFileLoading()" title="{{ file()?.name }}" />
<div class="flex gap-4 bg-white flex-column h-full flex-1 p-4 h-full">
<div class="flex flex-column lg:flex-row gap-4 flex-1 h-full">
<div class="w-full h-full lg:w-6">
@if (safeLink) {
<iframe
[src]="safeLink"
(load)="isIframeLoading = false"
[hidden]="isIframeLoading"
title="Rendering of document"
marginheight="0"
frameborder="0"
allowfullscreen=""
class="full-image"
height="100%"
width="100%"
></iframe>
}
@if (isIframeLoading) {
<osf-loading-spinner></osf-loading-spinner>
}
</div>

<div class="w-full flex flex-column gap-4 lg:w-6">
<div class="metadata p-4 flex flex-column gap-2">
<h2>{{ 'common.labels.metadata' | translate }}</h2>
<p>{{ 'files.detail.fileMetadata.previewNotAvailable' | translate }}</p>
</div>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.metadata {
border: 1px solid var(--grey-2);
border-radius: 0.75rem;
}

.full-image {
min-height: 100vh;
min-width: 100%;
}
Loading
Loading