diff --git a/src/app/root/root.component.ts b/src/app/root/root.component.ts index aae991dca39..3dc555919c3 100644 --- a/src/app/root/root.component.ts +++ b/src/app/root/root.component.ts @@ -1,4 +1,4 @@ -import { map } from 'rxjs/operators'; +import { map, startWith } from 'rxjs/operators'; import { Component, Inject, Input, OnInit } from '@angular/core'; import { Router } from '@angular/router'; @@ -71,7 +71,8 @@ export class RootComponent implements OnInit { const sidebarCollapsed = this.menuService.isMenuCollapsed(MenuID.ADMIN); this.slideSidebarOver = combineLatestObservable([sidebarCollapsed, this.windowService.isXsOrSm()]) .pipe( - map(([collapsed, mobile]) => collapsed || mobile) + map(([collapsed, mobile]) => collapsed || mobile), + startWith(true), ); if (this.router.url === getPageInternalServerErrorRoute()) { diff --git a/src/app/search-navbar/search-navbar.component.html b/src/app/search-navbar/search-navbar.component.html index e1de59ce512..e7180ef0260 100644 --- a/src/app/search-navbar/search-navbar.component.html +++ b/src/app/search-navbar/search-navbar.component.html @@ -4,7 +4,7 @@ - + diff --git a/src/app/shared/loading-csr/loading-csr.component.html b/src/app/shared/loading-csr/loading-csr.component.html new file mode 100644 index 00000000000..6565f7d7269 --- /dev/null +++ b/src/app/shared/loading-csr/loading-csr.component.html @@ -0,0 +1 @@ +
diff --git a/src/app/shared/loading-csr/loading-csr.component.scss b/src/app/shared/loading-csr/loading-csr.component.scss new file mode 100644 index 00000000000..f2e02b84452 --- /dev/null +++ b/src/app/shared/loading-csr/loading-csr.component.scss @@ -0,0 +1,20 @@ +.csr-progress-bar { + background: linear-gradient(to left, transparent 50%, var(--ds-csr-loading-color) 50%); + background-size: var(--ds-csr-loading-dash); + height: var(--ds-csr-loading-height); + width: 100%; + + // make sure it stays above the navbar but below the admin sidebar + z-index: calc(var(--ds-sidebar-z-index) - 1); + + animation: csr-loading-animation 0.5s linear infinite; +} + +@keyframes csr-loading-animation { + 0% { + background-position-x: 0 + } + 100% { + background-position-x: var(--ds-csr-loading-dash) + } +} diff --git a/src/app/shared/loading-csr/loading-csr.component.spec.ts b/src/app/shared/loading-csr/loading-csr.component.spec.ts new file mode 100644 index 00000000000..5b6848d71b0 --- /dev/null +++ b/src/app/shared/loading-csr/loading-csr.component.spec.ts @@ -0,0 +1,46 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoadingCsrComponent } from './loading-csr.component'; +import { PLATFORM_ID } from '@angular/core'; + +describe('LoadingCsrComponent', () => { + let component: LoadingCsrComponent; + let fixture: ComponentFixture; + + const init = async (platformId) => { + + await TestBed.configureTestingModule({ + declarations: [ LoadingCsrComponent ], + providers: [ + { + provide: PLATFORM_ID, + useValue: platformId, + }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(LoadingCsrComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + describe('on the server', () => { + beforeEach(async () => { + await init('server'); + }); + + it('should have loading=true', () => { + expect(component.loading).toBe(true); + }); + }); + + describe('in the browser', () => { + beforeEach(async () => { + await init('browser'); + }); + + it('should have loading=false', () => { + expect(component.loading).toBe(false); + }); + }); +}); diff --git a/src/app/shared/loading-csr/loading-csr.component.ts b/src/app/shared/loading-csr/loading-csr.component.ts new file mode 100644 index 00000000000..a18b4b67590 --- /dev/null +++ b/src/app/shared/loading-csr/loading-csr.component.ts @@ -0,0 +1,20 @@ +import { Component, Inject, PLATFORM_ID } from '@angular/core'; +import { isPlatformServer } from '@angular/common'; + +/** + * Shows a loading animation when rendered on the server + */ +@Component({ + selector: 'ds-loading-csr', + templateUrl: './loading-csr.component.html', + styleUrls: ['./loading-csr.component.scss'] +}) +export class LoadingCsrComponent { + loading: boolean; + + constructor( + @Inject(PLATFORM_ID) private platformId: any, + ) { + this.loading = isPlatformServer(this.platformId); + } +} diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index f40ddd5b900..f8173d3e6eb 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -293,6 +293,7 @@ import { BrowserOnlyPipe } from './utils/browser-only.pipe'; import { ThemedLoadingComponent } from './loading/themed-loading.component'; import { PersonPageClaimButtonComponent } from './dso-page/person-page-claim-button/person-page-claim-button.component'; import { SearchExportCsvComponent } from './search/search-export-csv/search-export-csv.component'; +import { LoadingCsrComponent } from './loading-csr/loading-csr.component'; const MODULES = [ CommonModule, @@ -351,6 +352,7 @@ const COMPONENTS = [ LangSwitchComponent, LoadingComponent, ThemedLoadingComponent, + LoadingCsrComponent, LogInComponent, LogOutComponent, NumberPickerComponent, diff --git a/src/app/statistics/google-analytics.service.spec.ts b/src/app/statistics/google-analytics.service.spec.ts index 0c6bc2bc51f..043f139199c 100644 --- a/src/app/statistics/google-analytics.service.spec.ts +++ b/src/app/statistics/google-analytics.service.spec.ts @@ -6,6 +6,7 @@ import { createSuccessfulRemoteDataObject$ } from '../shared/remote-data.utils'; import { ConfigurationProperty } from '../core/shared/configuration-property.model'; +import { NgZone } from '@angular/core'; describe('GoogleAnalyticsService', () => { const trackingIdProp = 'google.analytics.key'; @@ -51,7 +52,7 @@ describe('GoogleAnalyticsService', () => { body: bodyElementSpy, }); - service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy); + service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy, new NgZone({})); }); it('should be created', () => { @@ -71,7 +72,7 @@ describe('GoogleAnalyticsService', () => { findByPropertyName: createFailedRemoteDataObject$(), }); - service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy); + service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy, new NgZone({})); }); it('should NOT add a script to the body', () => { @@ -89,7 +90,7 @@ describe('GoogleAnalyticsService', () => { describe('when the tracking id is empty', () => { beforeEach(() => { configSpy = createConfigSuccessSpy(); - service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy); + service = new GoogleAnalyticsService(angularticsSpy, configSpy, documentSpy, new NgZone({})); }); it('should NOT add a script to the body', () => { diff --git a/src/app/statistics/google-analytics.service.ts b/src/app/statistics/google-analytics.service.ts index 0b52f54c4f0..107ffb666f4 100644 --- a/src/app/statistics/google-analytics.service.ts +++ b/src/app/statistics/google-analytics.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@angular/core'; +import { Inject, Injectable, NgZone } from '@angular/core'; import { Angulartics2GoogleAnalytics } from 'angulartics2'; import { ConfigurationDataService } from '../core/data/configuration-data.service'; import { getFirstCompletedRemoteData } from '../core/shared/operators'; @@ -16,6 +16,7 @@ export class GoogleAnalyticsService { private angulartics: Angulartics2GoogleAnalytics, private configService: ConfigurationDataService, @Inject(DOCUMENT) private document: any, + private zone: NgZone, ) { } /** @@ -25,28 +26,30 @@ export class GoogleAnalyticsService { * page and starts tracking. */ addTrackingIdToPage(): void { - this.configService.findByPropertyName('google.analytics.key').pipe( - getFirstCompletedRemoteData(), - ).subscribe((remoteData) => { - // make sure we got a success response from the backend - if (!remoteData.hasSucceeded) { return; } + this.zone.runOutsideAngular(() => { + this.configService.findByPropertyName('google.analytics.key').pipe( + getFirstCompletedRemoteData(), + ).subscribe((remoteData) => { + // make sure we got a success response from the backend + if (!remoteData.hasSucceeded) { return; } - const trackingId = remoteData.payload.values[0]; + const trackingId = remoteData.payload.values[0]; - // make sure we received a tracking id - if (isEmpty(trackingId)) { return; } + // make sure we received a tracking id + if (isEmpty(trackingId)) { return; } - // add trackingId snippet to page - const keyScript = this.document.createElement('script'); - keyScript.innerHTML = `(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ - (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), - m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) - })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); - ga('create', '${trackingId}', 'auto');`; - this.document.body.appendChild(keyScript); + // add trackingId snippet to page + const keyScript = this.document.createElement('script'); + keyScript.innerHTML = `(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ + (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), + m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) + })(window,document,'script','https://www.google-analytics.com/analytics.js','ga'); + ga('create', '${trackingId}', 'auto');`; + this.document.body.appendChild(keyScript); - // start tracking - this.angulartics.startTracking(); + // start tracking + this.angulartics.startTracking(); + }); }); } } diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index dc3be0de30a..ae4498170d1 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -38,6 +38,7 @@ import { extendEnvironmentWithAppConfig } from '../../config/config.util'; import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; import { environment } from '../../environments/environment'; +import { DOCUMENT } from '@angular/common'; export const REQ_KEY = makeStateKey('req'); @@ -77,7 +78,8 @@ export function getRequest(transferState: TransferState): any { useFactory: ( transferState: TransferState, dspaceTransferState: DSpaceTransferState, - correlationIdService: CorrelationIdService + correlationIdService: CorrelationIdService, + document: any, ) => { if (transferState.hasKey(APP_CONFIG_STATE)) { const appConfig = transferState.get(APP_CONFIG_STATE, new DefaultAppConfig()); @@ -87,10 +89,23 @@ export function getRequest(transferState: TransferState): any { return () => dspaceTransferState.transfer().then((b: boolean) => { correlationIdService.initCorrelationId(); + + // Workaround for flash of unstyled content during preboot: + // Adapted from https://github.com/angular/preboot/issues/75#issuecomment-421266570 + const styles: any[] = Array.prototype.slice.apply(document.querySelectorAll(`style[ng-transition]`)); + styles.forEach(el => { + // Remove ng-transition attribute from SSR styles to prevent Angular appInitializerFactory + // from removing them before preboot has completed + el.removeAttribute('ng-transition'); + }); + document.addEventListener('PrebootComplete', () => { + // Once preboot is finished, remove SSR styles + styles.forEach(el => el.remove()); + }); return b; }); }, - deps: [TransferState, DSpaceTransferState, CorrelationIdService], + deps: [TransferState, DSpaceTransferState, CorrelationIdService, DOCUMENT], multi: true }, { diff --git a/src/styles/_custom_variables.scss b/src/styles/_custom_variables.scss index 40180d8342a..ae675bc2851 100644 --- a/src/styles/_custom_variables.scss +++ b/src/styles/_custom_variables.scss @@ -87,4 +87,9 @@ --ds-search-form-scope-max-width: 150px; --ds-gap: 0.25rem; + + --ds-csr-loading-color: var(--bs-green); + --ds-csr-loading-height: 2px; + --ds-csr-loading-dash: 20px; + --ds-csr-loading-color: var(--bs-green); } diff --git a/yarn.lock b/yarn.lock index 1996ace1b1f..64a44cd9df5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1956,6 +1956,13 @@ resolved "https://registry.yarnpkg.com/@researchgate/react-intersection-observer/-/react-intersection-observer-1.3.5.tgz#0321d2dd609aaacdb9bace8004d99c72824fb142" integrity sha512-aYlsex5Dd6BAHMJvJrUoFp8gzgMSL27xFvrxkVYW0bV1RMAapVsO+QeYLtTaSF/QCflktODodvv+wJm49oMnnQ== +"@rezonant/preboot@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@rezonant/preboot/-/preboot-8.0.0.tgz#dc201887c4cfe8e20f58feefbfef7851921dcf86" + integrity sha512-t3ViT5ns3EYezQ5pnMvwqVe1ki6E0jqa6y2QFFFCaEADKoYZO6tyb+dDknhPQ0LiegftcG3DG1igoA5crNzlLQ== + dependencies: + tslib "^2.0.0" + "@scarf/scarf@^1.1.0": version "1.1.1" resolved "https://registry.yarnpkg.com/@scarf/scarf/-/scarf-1.1.1.tgz#d8b9f20037b3a37dbf8dcdc4b3b72f9285bfce35"