diff --git a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts index 54d144da51..d777c2ca52 100644 --- a/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts +++ b/src/app/access-control/group-registry/group-form/members-list/members-list.component.ts @@ -10,7 +10,7 @@ import { combineLatest as observableCombineLatest, ObservedValueOf, } from 'rxjs'; -import { map, mergeMap, switchMap, take } from 'rxjs/operators'; +import { defaultIfEmpty, map, mergeMap, switchMap, take } from 'rxjs/operators'; import {buildPaginatedList, PaginatedList} from '../../../../core/data/paginated-list.model'; import { RemoteData } from '../../../../core/data/remote-data'; import { EPersonDataService } from '../../../../core/eperson/eperson-data.service'; @@ -25,6 +25,7 @@ import { NotificationsService } from '../../../../shared/notifications/notificat import { PaginationComponentOptions } from '../../../../shared/pagination/pagination-component-options.model'; import {EpersonDtoModel} from '../../../../core/eperson/models/eperson-dto.model'; import { PaginationService } from '../../../../core/pagination/pagination.service'; +import { isNotEmpty } from '../../../../shared/empty.util'; /** * Keys to keep track of specific subscriptions @@ -144,7 +145,7 @@ export class MembersListComponent implements OnInit, OnDestroy { } }), switchMap((epersonListRD: RemoteData>) => { - const dtos$ = observableCombineLatest(...epersonListRD.payload.page.map((member: EPerson) => { + const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { const dto$: Observable = observableCombineLatest( this.isMemberOfGroup(member), (isMember: ObservedValueOf>) => { const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); @@ -153,8 +154,8 @@ export class MembersListComponent implements OnInit, OnDestroy { return epersonDtoModel; }); return dto$; - })); - return dtos$.pipe(map((dtos: EpersonDtoModel[]) => { + })]); + return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); })); })) @@ -174,7 +175,7 @@ export class MembersListComponent implements OnInit, OnDestroy { return this.ePersonDataService.findAllByHref(group._links.epersons.href, { currentPage: 1, elementsPerPage: 9999 - }, false) + }) .pipe( getFirstSucceededRemoteData(), getRemoteDataPayload(), @@ -274,7 +275,7 @@ export class MembersListComponent implements OnInit, OnDestroy { } }), switchMap((epersonListRD: RemoteData>) => { - const dtos$ = observableCombineLatest(...epersonListRD.payload.page.map((member: EPerson) => { + const dtos$ = observableCombineLatest([...epersonListRD.payload.page.map((member: EPerson) => { const dto$: Observable = observableCombineLatest( this.isMemberOfGroup(member), (isMember: ObservedValueOf>) => { const epersonDtoModel: EpersonDtoModel = new EpersonDtoModel(); @@ -283,8 +284,8 @@ export class MembersListComponent implements OnInit, OnDestroy { return epersonDtoModel; }); return dto$; - })); - return dtos$.pipe(map((dtos: EpersonDtoModel[]) => { + })]); + return dtos$.pipe(defaultIfEmpty([]), map((dtos: EpersonDtoModel[]) => { return buildPaginatedList(epersonListRD.payload.pageInfo, dtos); })); })) diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index f2243d435e..8bec7edc80 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -32,7 +32,6 @@ import { storeModuleConfig } from './app.reducer'; import { LocaleService } from './core/locale/locale.service'; import { authReducer } from './core/auth/auth.reducer'; import { provideMockStore } from '@ngrx/store/testing'; -import { GoogleAnalyticsService } from './statistics/google-analytics.service'; import { ThemeService } from './shared/theme-support/theme.service'; import { getMockThemeService } from './shared/mocks/theme-service.mock'; import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; @@ -46,16 +45,16 @@ const initialState = { core: { auth: { loading: false } } }; +export function getMockLocaleService(): LocaleService { + return jasmine.createSpyObj('LocaleService', { + setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode') + }); +} + describe('App component', () => { let breadcrumbsServiceSpy; - function getMockLocaleService(): LocaleService { - return jasmine.createSpyObj('LocaleService', { - setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode') - }); - } - const getDefaultTestBedConf = () => { breadcrumbsServiceSpy = jasmine.createSpyObj(['listenForRouteChanges']); @@ -130,66 +129,4 @@ describe('App component', () => { }); }); - - describe('the constructor', () => { - it('should call breadcrumbsService.listenForRouteChanges', () => { - expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1); - }); - }); - - describe('when GoogleAnalyticsService is provided', () => { - let googleAnalyticsSpy; - - beforeEach(() => { - // NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset - TestBed.resetTestingModule(); - TestBed.configureTestingModule(getDefaultTestBedConf()); - googleAnalyticsSpy = jasmine.createSpyObj('googleAnalyticsService', [ - 'addTrackingIdToPage', - ]); - TestBed.overrideProvider(GoogleAnalyticsService, {useValue: googleAnalyticsSpy}); - fixture = TestBed.createComponent(AppComponent); - comp = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create component', () => { - expect(comp).toBeTruthy(); - }); - - describe('the constructor', () => { - it('should call googleAnalyticsService.addTrackingIdToPage()', () => { - expect(googleAnalyticsSpy.addTrackingIdToPage).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('when ThemeService returns a custom theme', () => { - let document; - let headSpy; - - beforeEach(() => { - // NOTE: Cannot override providers once components have been compiled, so TestBed needs to be reset - TestBed.resetTestingModule(); - TestBed.configureTestingModule(getDefaultTestBedConf()); - TestBed.overrideProvider(ThemeService, {useValue: getMockThemeService('custom')}); - document = TestBed.inject(DOCUMENT); - headSpy = jasmine.createSpyObj('head', ['appendChild', 'getElementsByClassName']); - headSpy.getElementsByClassName.and.returnValue([]); - spyOn(document, 'getElementsByTagName').and.returnValue([headSpy]); - fixture = TestBed.createComponent(AppComponent); - comp = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should append a link element with the correct attributes to the head element', () => { - const link = document.createElement('link'); - link.setAttribute('rel', 'stylesheet'); - link.setAttribute('type', 'text/css'); - link.setAttribute('class', 'theme-css'); - link.setAttribute('href', 'custom-theme.css'); - - expect(headSpy.appendChild).toHaveBeenCalledWith(link); - }); - }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ee8c4d685f..7aa1043da1 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,4 +1,4 @@ -import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators'; +import { distinctUntilChanged, take, withLatestFrom } from 'rxjs/operators'; import { DOCUMENT, isPlatformBrowser } from '@angular/common'; import { AfterViewInit, @@ -7,48 +7,30 @@ import { HostListener, Inject, OnInit, - Optional, PLATFORM_ID, } from '@angular/core'; import { - ActivatedRouteSnapshot, - ActivationEnd, NavigationCancel, NavigationEnd, - NavigationStart, ResolveEnd, + NavigationStart, Router, } from '@angular/router'; -import { isEqual } from 'lodash'; -import { BehaviorSubject, Observable, of } from 'rxjs'; +import { BehaviorSubject, Observable } from 'rxjs'; import { select, Store } from '@ngrx/store'; import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap'; import { TranslateService } from '@ngx-translate/core'; -import { Angulartics2GoogleAnalytics } from 'angulartics2'; - -import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; import { HostWindowState } from './shared/search/host-window.reducer'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { isAuthenticationBlocking } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; -import { MenuService } from './shared/menu/menu.service'; -import { HostWindowService } from './shared/host-window.service'; -import { HeadTagConfig, ThemeConfig } from '../config/theme.model'; -import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { environment } from '../environments/environment'; import { models } from './core/core.module'; -import { LocaleService } from './core/locale/locale.service'; -import { hasNoValue, hasValue, isNotEmpty } from './shared/empty.util'; -import { KlaroService } from './shared/cookies/klaro.service'; -import { GoogleAnalyticsService } from './statistics/google-analytics.service'; import { ThemeService } from './shared/theme-support/theme.service'; -import { BASE_THEME_NAME } from './shared/theme-support/theme.constants'; -import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; import { IdleModalComponent } from './shared/idle-modal/idle-modal.component'; -import { getDefaultThemeConfig } from '../config/config.util'; -import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface'; +import { distinctNext } from './core/shared/distinct-next'; import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface'; @Component({ @@ -58,11 +40,6 @@ import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.int changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent implements OnInit, AfterViewInit { - sidebarVisible: Observable; - slideSidebarOver: Observable; - collapsedSidebarWidth: Observable; - totalSidebarWidth: Observable; - theme: Observable = of({} as any); notificationOptions; models; @@ -79,9 +56,7 @@ export class AppComponent implements OnInit, AfterViewInit { /** * Whether or not the theme is in the process of being swapped */ - isThemeLoading$: BehaviorSubject = new BehaviorSubject(false); - - isThemeCSSLoading$: BehaviorSubject = new BehaviorSubject(false); + isThemeLoading$: Observable; /** * Whether or not the idle modal is is currently open @@ -92,78 +67,26 @@ export class AppComponent implements OnInit, AfterViewInit { @Inject(NativeWindowService) private _window: NativeWindowRef, @Inject(DOCUMENT) private document: any, @Inject(PLATFORM_ID) private platformId: any, - @Inject(APP_CONFIG) private appConfig: AppConfig, private themeService: ThemeService, private translate: TranslateService, private store: Store, - private metadata: MetadataService, - private angulartics2GoogleAnalytics: Angulartics2GoogleAnalytics, - private angulartics2DSpace: Angulartics2DSpace, private authService: AuthService, private router: Router, private cssService: CSSVariableService, - private menuService: MenuService, - private windowService: HostWindowService, - private localeService: LocaleService, - private breadcrumbsService: BreadcrumbsService, private modalService: NgbModal, private modalConfig: NgbModalConfig, - @Optional() private cookiesService: KlaroService, - @Optional() private googleAnalyticsService: GoogleAnalyticsService, ) { - - if (!isEqual(environment, this.appConfig)) { - throw new Error('environment does not match app config!'); - } - this.notificationOptions = environment.notifications; /* Use models object so all decorators are actually called */ this.models = models; - this.themeService.getThemeName$().subscribe((themeName: string) => { - if (isPlatformBrowser(this.platformId)) { - // the theme css will never download server side, so this should only happen on the browser - this.distinctNext(this.isThemeCSSLoading$, true); - } - if (hasValue(themeName)) { - this.loadGlobalThemeConfig(themeName); - } else { - const defaultThemeConfig = getDefaultThemeConfig(); - if (hasValue(defaultThemeConfig)) { - this.loadGlobalThemeConfig(defaultThemeConfig.name); - } else { - this.loadGlobalThemeConfig(BASE_THEME_NAME); - } - } - }); - if (isPlatformBrowser(this.platformId)) { - this.authService.trackTokenExpiration(); this.trackIdleModal(); } - // Load all the languages that are defined as active from the config file - translate.addLangs(environment.languages.filter((LangConfig) => LangConfig.active === true).map((a) => a.code)); + this.isThemeLoading$ = this.themeService.isThemeLoading$; - // Load the default language from the config file - // translate.setDefaultLang(environment.defaultLanguage); - - // set the current language code - this.localeService.setCurrentLanguageCode(); - - // analytics - if (hasValue(googleAnalyticsService)) { - googleAnalyticsService.addTrackingIdToPage(); - } - angulartics2DSpace.startTracking(); - - metadata.listenForRouteChange(); - breadcrumbsService.listenForRouteChanges(); - - if (environment.debug) { - console.info(environment); - } this.storeCSSVariables(); } @@ -178,18 +101,11 @@ export class AppComponent implements OnInit, AfterViewInit { return true; }; - this.isAuthBlocking$ = this.store.pipe(select(isAuthenticationBlocking)).pipe( + this.isAuthBlocking$ = this.store.pipe( + select(isAuthenticationBlocking), distinctUntilChanged() ); - this.isAuthBlocking$ - .pipe( - filter((isBlocking: boolean) => isBlocking === false), - take(1) - ).subscribe(() => this.initializeKlaro()); - const env: string = environment.production ? 'Production' : 'Development'; - const color: string = environment.production ? 'red' : 'green'; - console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); this.dispatchWindowSize(this._window.nativeWindow.innerWidth, this._window.nativeWindow.innerHeight); } @@ -209,57 +125,18 @@ export class AppComponent implements OnInit, AfterViewInit { } ngAfterViewInit() { - let updatingTheme = false; - let snapshot: ActivatedRouteSnapshot; - this.router.events.subscribe((event) => { if (event instanceof NavigationStart) { - updatingTheme = false; - this.distinctNext(this.isRouteLoading$, true); - } else if (event instanceof ResolveEnd) { - // this is the earliest point where we have all the information we need - // to update the theme, but this event is not emitted on first load - this.updateTheme(event.urlAfterRedirects, event.state.root); - updatingTheme = true; - } else if (!updatingTheme && event instanceof ActivationEnd) { - // if there was no ResolveEnd, keep track of the snapshot... - snapshot = event.snapshot; - } else if (event instanceof NavigationEnd) { - if (!updatingTheme) { - // ...and use it to update the theme on NavigationEnd instead - this.updateTheme(event.urlAfterRedirects, snapshot); - updatingTheme = true; - } - this.distinctNext(this.isRouteLoading$, false); - } else if (event instanceof NavigationCancel) { - if (!updatingTheme) { - this.distinctNext(this.isThemeLoading$, false); - } - this.distinctNext(this.isRouteLoading$, false); + distinctNext(this.isRouteLoading$, true); + } else if ( + event instanceof NavigationEnd || + event instanceof NavigationCancel + ) { + distinctNext(this.isRouteLoading$, false); } }); } - /** - * Update the theme according to the current route, if applicable. - * @param urlAfterRedirects the current URL after redirects - * @param snapshot the current route snapshot - * @private - */ - private updateTheme(urlAfterRedirects: string, snapshot: ActivatedRouteSnapshot): void { - this.themeService.updateThemeOnRouteChange$(urlAfterRedirects, snapshot).pipe( - switchMap((changed) => { - if (changed) { - return this.isThemeCSSLoading$; - } else { - return [false]; - } - }) - ).subscribe((changed) => { - this.distinctNext(this.isThemeLoading$, changed); - }); - } - @HostListener('window:resize', ['$event']) public onResize(event): void { this.dispatchWindowSize(event.target.innerWidth, event.target.innerHeight); @@ -271,125 +148,6 @@ export class AppComponent implements OnInit, AfterViewInit { ); } - private initializeKlaro() { - if (hasValue(this.cookiesService)) { - this.cookiesService.initialize(); - } - } - - private loadGlobalThemeConfig(themeName: string): void { - this.setThemeCss(themeName); - this.setHeadTags(themeName); - } - - /** - * Update the theme css file in - * - * @param themeName The name of the new theme - * @private - */ - private setThemeCss(themeName: string): void { - const head = this.document.getElementsByTagName('head')[0]; - if (hasNoValue(head)) { - return; - } - - // Array.from to ensure we end up with an array, not an HTMLCollection, which would be - // automatically updated if we add nodes later - const currentThemeLinks = Array.from(head.getElementsByClassName('theme-css')); - const link = this.document.createElement('link'); - link.setAttribute('rel', 'stylesheet'); - link.setAttribute('type', 'text/css'); - link.setAttribute('class', 'theme-css'); - link.setAttribute('href', `${encodeURIComponent(themeName)}-theme.css`); - // wait for the new css to download before removing the old one to prevent a - // flash of unstyled content - link.onload = () => { - if (isNotEmpty(currentThemeLinks)) { - currentThemeLinks.forEach((currentThemeLink: any) => { - if (hasValue(currentThemeLink)) { - currentThemeLink.remove(); - } - }); - } - // the fact that this callback is used, proves we're on the browser. - this.distinctNext(this.isThemeCSSLoading$, false); - }; - head.appendChild(link); - } - - private setHeadTags(themeName: string): void { - const head = this.document.getElementsByTagName('head')[0]; - if (hasNoValue(head)) { - return; - } - - // clear head tags - const currentHeadTags = Array.from(head.getElementsByClassName('theme-head-tag')); - if (hasValue(currentHeadTags)) { - currentHeadTags.forEach((currentHeadTag: any) => currentHeadTag.remove()); - } - - // create new head tags (not yet added to DOM) - const headTagFragment = this.document.createDocumentFragment(); - this.createHeadTags(themeName) - .forEach(newHeadTag => headTagFragment.appendChild(newHeadTag)); - - // add new head tags to DOM - head.appendChild(headTagFragment); - } - - private createHeadTags(themeName: string): HTMLElement[] { - const themeConfig = this.themeService.getThemeConfigFor(themeName); - const headTagConfigs = themeConfig?.headTags; - - if (hasNoValue(headTagConfigs)) { - const parentThemeName = themeConfig?.extends; - if (hasValue(parentThemeName)) { - // inherit the head tags of the parent theme - return this.createHeadTags(parentThemeName); - } - const defaultThemeConfig = getDefaultThemeConfig(); - const defaultThemeName = defaultThemeConfig.name; - if ( - hasNoValue(defaultThemeName) || - themeName === defaultThemeName || - themeName === BASE_THEME_NAME - ) { - // last resort, use fallback favicon.ico - return [ - this.createHeadTag({ - 'tagName': 'link', - 'attributes': { - 'rel': 'icon', - 'href': 'assets/images/favicon.ico', - 'sizes': 'any', - } - }) - ]; - } - - // inherit the head tags of the default theme - return this.createHeadTags(defaultThemeConfig.name); - } - - return headTagConfigs.map(this.createHeadTag.bind(this)); - } - - private createHeadTag(headTagConfig: HeadTagConfig): HTMLElement { - const tag = this.document.createElement(headTagConfig.tagName); - - if (hasValue(headTagConfig.attributes)) { - Object.entries(headTagConfig.attributes) - .forEach(([key, value]) => tag.setAttribute(key, value)); - } - - // 'class' attribute should always be 'theme-head-tag' for removal - tag.setAttribute('class', 'theme-head-tag'); - - return tag; - } - private trackIdleModal() { const isIdle$ = this.authService.isUserIdle(); const isAuthenticated$ = this.authService.isAuthenticated(); @@ -409,16 +167,4 @@ export class AppComponent implements OnInit, AfterViewInit { }); } - /** - * Use nextValue to update a given BehaviorSubject, only if it differs from its current value - * - * @param bs a BehaviorSubject - * @param nextValue the next value for that BehaviorSubject - * @protected - */ - protected distinctNext(bs: BehaviorSubject, nextValue: T): void { - if (bs.getValue() !== nextValue) { - bs.next(nextValue); - } - } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index ebf9aa4937..392969d041 100755 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -1,18 +1,14 @@ import { APP_BASE_HREF, CommonModule, DOCUMENT } from '@angular/common'; import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; -import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { NgModule } from '@angular/core'; import { AbstractControl } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { EffectsModule } from '@ngrx/effects'; import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store'; -import { MetaReducer, Store, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; -import { - DYNAMIC_ERROR_MESSAGES_MATCHER, - DYNAMIC_MATCHER_PROVIDERS, - DynamicErrorMessagesMatcher -} from '@ng-dynamic-forms/core'; +import { MetaReducer, StoreModule, USER_PROVIDED_META_REDUCERS } from '@ngrx/store'; +import { DYNAMIC_ERROR_MESSAGES_MATCHER, DYNAMIC_MATCHER_PROVIDERS, DynamicErrorMessagesMatcher } from '@ng-dynamic-forms/core'; import { TranslateModule } from '@ngx-translate/core'; import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to'; import { AppRoutingModule } from './app-routing.module'; @@ -20,7 +16,6 @@ import { AppComponent } from './app.component'; import { appEffects } from './app.effects'; import { appMetaReducers, debugMetaReducers } from './app.metareducers'; import { appReducers, AppState, storeModuleConfig } from './app.reducer'; -import { CheckAuthenticationTokenAction } from './core/auth/auth.actions'; import { CoreModule } from './core/core.module'; import { ClientCookieService } from './core/services/client-cookie.service'; import { NavbarModule } from './navbar/navbar.module'; @@ -32,7 +27,6 @@ import { LocaleInterceptor } from './core/locale/locale.interceptor'; import { XsrfInterceptor } from './core/xsrf/xsrf.interceptor'; import { LogInterceptor } from './core/log/log.interceptor'; import { EagerThemesModule } from '../themes/eager-themes.module'; - import { APP_CONFIG, AppConfig } from '../config/app-config.interface'; import { NgxMaskModule } from 'ngx-mask'; import { StoreDevModules } from '../config/store/devtools'; @@ -80,10 +74,6 @@ const IMPORTS = [ ]; const PROVIDERS = [ - { - provide: APP_CONFIG, - useFactory: getConfig - }, { provide: APP_BASE_HREF, useFactory: getBaseHref, @@ -99,15 +89,6 @@ const PROVIDERS = [ useClass: DSpaceRouterStateSerializer }, ClientCookieService, - // Check the authentication token when the app initializes - { - provide: APP_INITIALIZER, - useFactory: (store: Store,) => { - return () => store.dispatch(new CheckAuthenticationTokenAction()); - }, - deps: [Store], - multi: true - }, // register AuthInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS, diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index 999ea863df..3034c00197 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -4,7 +4,7 @@ import { HttpHeaders } from '@angular/common/http'; import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens'; import { Observable, of as observableOf } from 'rxjs'; -import { map, startWith, switchMap, take } from 'rxjs/operators'; +import { filter, map, startWith, switchMap, take } from 'rxjs/operators'; import { select, Store } from '@ngrx/store'; import { CookieAttributes } from 'js-cookie'; @@ -93,6 +93,8 @@ export class AuthService { private translateService: TranslateService ) { this.store.pipe( + // when this service is constructed the store is not fully initialized yet + filter((state: any) => state?.core?.auth !== undefined), select(isAuthenticated), startWith(false) ).subscribe((authenticated: boolean) => this._authenticated = authenticated); @@ -346,7 +348,7 @@ export class AuthService { let token: AuthTokenInfo; let currentlyRefreshingToken = false; this.store.pipe(select(getAuthenticationToken)).subscribe((authTokenInfo: AuthTokenInfo) => { - // If new token is undefined an it wasn't previously => Refresh failed + // If new token is undefined and it wasn't previously => Refresh failed if (currentlyRefreshingToken && token !== undefined && authTokenInfo === undefined) { // Token refresh failed => Error notification => 10 second wait => Page reloads & user logged out this.notificationService.error(this.translateService.get('auth.messages.token-refresh-failed')); diff --git a/src/app/core/cache/builders/remote-data-build.service.spec.ts b/src/app/core/cache/builders/remote-data-build.service.spec.ts index 1d22da494f..befeabdd90 100644 --- a/src/app/core/cache/builders/remote-data-build.service.spec.ts +++ b/src/app/core/cache/builders/remote-data-build.service.spec.ts @@ -18,6 +18,7 @@ import { take } from 'rxjs/operators'; import { HALLink } from '../../shared/hal-link.model'; import { RequestEntryState } from '../../data/request-entry-state.model'; import { RequestEntry } from '../../data/request-entry.model'; +import { cold } from 'jasmine-marbles'; describe('RemoteDataBuildService', () => { let service: RemoteDataBuildService; @@ -646,4 +647,120 @@ describe('RemoteDataBuildService', () => { }); }); }); + + describe('buildFromHref', () => { + beforeEach(() => { + (objectCache.getRequestUUIDBySelfLink as jasmine.Spy).and.returnValue(cold('a', { a: 'request/uuid' })); + }); + + describe('when both getRequestFromRequestHref and getRequestFromRequestUUID emit nothing', () => { + beforeEach(() => { + (requestService.getByHref as jasmine.Spy).and.returnValue(cold('a', { a: undefined })); + (requestService.getByUUID as jasmine.Spy).and.returnValue(cold('a', { a: undefined })); + }); + + it('should not emit anything', () => { + expect(service.buildFromHref(cold('a', { a: 'rest/api/endpoint' }))).toBeObservable(cold('')); + }); + }); + + describe('when one of getRequestFromRequestHref or getRequestFromRequestUUID emits nothing', () => { + let requestEntry: RequestEntry; + + beforeEach(() => { + requestEntry = Object.assign(new RequestEntry(), { + state: RequestEntryState.Success, + request: {}, + }); + (requestService.getByHref as jasmine.Spy).and.returnValue(cold('a', { a: undefined })); + (requestService.getByUUID as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry })); + spyOn((service as any), 'buildPayload').and.returnValue(cold('a', { a: {} })); + }); + + it('should create remote-data with the existing request-entry', () => { + expect(service.buildFromHref(cold('a', { a: 'rest/api/endpoint' }))).toBeObservable(cold('a', { + a: new RemoteData(undefined, undefined, undefined, RequestEntryState.Success, undefined, {}, undefined), + })); + }); + }); + + describe('when one of getRequestFromRequestHref or getRequestFromRequestUUID is stale', () => { + let requestEntry1: RequestEntry; + let requestEntry2: RequestEntry; + + beforeEach(() => { + requestEntry1 = Object.assign(new RequestEntry(), { + state: RequestEntryState.Success, + request: {}, + }); + requestEntry2 = Object.assign(new RequestEntry(), { + state: RequestEntryState.SuccessStale, + request: {}, + }); + (requestService.getByHref as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry1 })); + (requestService.getByUUID as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry2 })); + spyOn((service as any), 'buildPayload').and.returnValue(cold('a', { a: {} })); + }); + + it('should create remote-data with the non-stale request-entry', () => { + expect(service.buildFromHref(cold('a', { a: 'rest/api/endpoint' }))).toBeObservable(cold('a', { + a: new RemoteData(undefined, undefined, undefined, RequestEntryState.Success, undefined, {}, undefined), + })); + }); + }); + + describe('when both getRequestFromRequestHref and getRequestFromRequestUUID are stale', () => { + let requestEntry1: RequestEntry; + let requestEntry2: RequestEntry; + + beforeEach(() => { + requestEntry1 = Object.assign(new RequestEntry(), { + state: RequestEntryState.SuccessStale, + request: {}, + lastUpdated: 20, + }); + requestEntry2 = Object.assign(new RequestEntry(), { + state: RequestEntryState.SuccessStale, + request: {}, + lastUpdated: 10, + }); + (requestService.getByHref as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry1 })); + (requestService.getByUUID as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry2 })); + spyOn((service as any), 'buildPayload').and.returnValue(cold('a', { a: {} })); + }); + + it('should create remote-data with the most up-to-date request-entry', () => { + expect(service.buildFromHref(cold('a', { a: 'rest/api/endpoint' }))).toBeObservable(cold('a', { + a: new RemoteData(undefined, undefined, 20, RequestEntryState.SuccessStale, undefined, {}, undefined), + })); + }); + }); + + describe('when both getRequestFromRequestHref and getRequestFromRequestUUID are not stale', () => { + let requestEntry1: RequestEntry; + let requestEntry2: RequestEntry; + + beforeEach(() => { + requestEntry1 = Object.assign(new RequestEntry(), { + state: RequestEntryState.Success, + request: {}, + lastUpdated: 25, + }); + requestEntry2 = Object.assign(new RequestEntry(), { + state: RequestEntryState.Success, + request: {}, + lastUpdated: 5, + }); + (requestService.getByHref as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry1 })); + (requestService.getByUUID as jasmine.Spy).and.returnValue(cold('a', { a: requestEntry2 })); + spyOn((service as any), 'buildPayload').and.returnValue(cold('a', { a: {} })); + }); + + it('should create remote-data with the most up-to-date request-entry', () => { + expect(service.buildFromHref(cold('a', { a: 'rest/api/endpoint' }))).toBeObservable(cold('a', { + a: new RemoteData(undefined, undefined, 25, RequestEntryState.Success, undefined, {}, undefined), + })); + }); + }); + }); }); diff --git a/src/app/core/cache/builders/remote-data-build.service.ts b/src/app/core/cache/builders/remote-data-build.service.ts index 016f6b16f6..8ceab63e03 100644 --- a/src/app/core/cache/builders/remote-data-build.service.ts +++ b/src/app/core/cache/builders/remote-data-build.service.ts @@ -3,9 +3,8 @@ import { combineLatest as observableCombineLatest, Observable, of as observableOf, - race as observableRace } from 'rxjs'; -import { map, switchMap, filter, distinctUntilKeyChanged } from 'rxjs/operators'; +import { map, switchMap, filter, distinctUntilKeyChanged, startWith } from 'rxjs/operators'; import { hasValue, isEmpty, isNotEmpty, hasNoValue, isUndefined } from '../../../shared/empty.util'; import { createSuccessfulRemoteDataObject$ } from '../../../shared/remote-data.utils'; import { FollowLinkConfig, followLink } from '../../../shared/utils/follow-link-config.model'; @@ -21,7 +20,7 @@ import { HALResource } from '../../shared/hal-resource.model'; import { PAGINATED_LIST } from '../../data/paginated-list.resource-type'; import { getUrlWithoutEmbedParams } from '../../index/index.selectors'; import { getResourceTypeValueFor } from '../object-cache.reducer'; -import { hasSucceeded, RequestEntryState } from '../../data/request-entry-state.model'; +import { hasSucceeded, isStale, RequestEntryState } from '../../data/request-entry-state.model'; import { getRequestFromRequestHref, getRequestFromRequestUUID } from '../../shared/request.operators'; import { RequestEntry } from '../../data/request-entry.model'; import { ResponseState } from '../../data/response-state.model'; @@ -207,10 +206,27 @@ export class RemoteDataBuildService { this.objectCache.getRequestUUIDBySelfLink(href)), ); - const requestEntry$ = observableRace( - href$.pipe(getRequestFromRequestHref(this.requestService)), - requestUUID$.pipe(getRequestFromRequestUUID(this.requestService)), - ).pipe( + const requestEntry$ = observableCombineLatest([ + href$.pipe(getRequestFromRequestHref(this.requestService), startWith(undefined)), + requestUUID$.pipe(getRequestFromRequestUUID(this.requestService), startWith(undefined)), + ]).pipe( + filter(([r1, r2]) => hasValue(r1) || hasValue(r2)), + map(([r1, r2]) => { + // If one of the two requests has no value, return the other (both is impossible due to the filter above) + if (hasNoValue(r2)) { + return r1; + } else if (hasNoValue(r1)) { + return r2; + } + + if ((isStale(r1.state) && isStale(r2.state)) || (!isStale(r1.state) && !isStale(r2.state))) { + // Neither or both are stale, pick the most recent request + return r1.lastUpdated >= r2.lastUpdated ? r1 : r2; + } else { + // One of the two is stale, return the not stale request + return isStale(r2.state) ? r1 : r2; + } + }), distinctUntilKeyChanged('lastUpdated') ); diff --git a/src/app/core/data/data.service.ts b/src/app/core/data/data.service.ts index 6176694d9d..695b2e5fea 100644 --- a/src/app/core/data/data.service.ts +++ b/src/app/core/data/data.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Store } from '@ngrx/store'; import { Operation } from 'fast-json-patch'; -import { AsyncSubject, combineLatest, from as observableFrom, Observable, of as observableOf } from 'rxjs'; +import { AsyncSubject, from as observableFrom, Observable, of as observableOf } from 'rxjs'; import { distinctUntilChanged, filter, @@ -166,7 +166,7 @@ export abstract class DataService implements UpdateDa */ buildHrefWithParams(href: string, params: RequestParam[], ...linksToFollow: FollowLinkConfig[]): string { - let args = []; + let args = []; if (hasValue(params)) { params.forEach((param: RequestParam) => { args = this.addHrefArg(href, args, `${param.fieldName}=${param.fieldValue}`); @@ -181,6 +181,7 @@ export abstract class DataService implements UpdateDa return href; } } + /** * Adds the embed options to the link for the request * @param href The href the params are to be added to @@ -487,7 +488,7 @@ export abstract class DataService implements UpdateDa } createPatchFromCache(object: T): Observable { - const oldVersion$ = this.findByHref(object._links.self.href, true, false); + const oldVersion$ = this.findByHref(object._links.self.href, true, false); return oldVersion$.pipe( getFirstSucceededRemoteData(), getRemoteDataPayload(), @@ -665,9 +666,17 @@ export abstract class DataService implements UpdateDa invalidated$.complete(); }); - return combineLatest([response$, invalidated$]).pipe( - filter(([_, invalidated]) => invalidated), - map(([response, _]) => response), + return response$.pipe( + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return invalidated$.pipe( + filter((invalidated: boolean) => invalidated), + map(() => rd) + ); + } else { + return [rd]; + } + }) ); } diff --git a/src/app/core/eperson/group-data.service.spec.ts b/src/app/core/eperson/group-data.service.spec.ts index c30b74e966..01c8632afa 100644 --- a/src/app/core/eperson/group-data.service.spec.ts +++ b/src/app/core/eperson/group-data.service.spec.ts @@ -26,6 +26,12 @@ import { EPersonMock, EPersonMock2 } from '../../shared/testing/eperson.mock'; import { createPaginatedList, createRequestEntry$ } from '../../shared/testing/utils.test'; import { CoreState } from '../core-state.model'; import { FindListOptions } from '../data/find-list-options.model'; +import { DataService } from '../data/data.service'; +import { ObjectCacheService } from '../cache/object-cache.service'; +import { getMockLinkService } from '../../shared/mocks/link-service.mock'; +import { DataServiceStub } from '../../shared/testing/data-service.stub'; +import { of as observableOf } from 'rxjs'; +import { ObjectCacheEntry } from '../cache/object-cache.reducer'; describe('GroupDataService', () => { let service: GroupDataService; @@ -38,6 +44,8 @@ describe('GroupDataService', () => { let groups$; let halService; let rdbService; + let objectCache: ObjectCacheService; + let dataService: DataServiceStub; function init() { restEndpointURL = 'https://dspace.4science.it/dspace-spring-rest/api/eperson'; @@ -46,6 +54,8 @@ describe('GroupDataService', () => { groups$ = createSuccessfulRemoteDataObject$(createPaginatedList(groups)); rdbService = getMockRemoteDataBuildServiceHrefMap(undefined, { 'https://dspace.4science.it/dspace-spring-rest/api/eperson/groups': groups$ }); halService = new HALEndpointServiceStub(restEndpointURL); + objectCache = new ObjectCacheService(store, getMockLinkService()); + dataService = new DataServiceStub(); TestBed.configureTestingModule({ imports: [ CommonModule, @@ -58,7 +68,9 @@ describe('GroupDataService', () => { }), ], declarations: [], - providers: [], + providers: [ + { provide: DataService, useValue: dataService }, + ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }); } @@ -71,7 +83,7 @@ describe('GroupDataService', () => { requestService, rdbService, store, - null, + objectCache, halService, null, ); @@ -109,6 +121,10 @@ describe('GroupDataService', () => { describe('addSubGroupToGroup', () => { beforeEach(() => { + spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2'] + } as ObjectCacheEntry)); + spyOn(dataService, 'invalidateByHref'); service.addSubGroupToGroup(GroupMock, GroupMock2).subscribe(); }); it('should send PostRequest to eperson/groups/group-id/subgroups endpoint with new subgroup link in body', () => { @@ -119,20 +135,40 @@ describe('GroupDataService', () => { const expected = new PostRequest(requestService.generateRequestId(), GroupMock.self + '/' + service.subgroupsEndpoint, GroupMock2.self, options); expect(requestService.send).toHaveBeenCalledWith(expected); }); + it('should invalidate the previous requests of the parent group', () => { + expect(objectCache.getByHref).toHaveBeenCalledWith(GroupMock._links.self.href); + expect(requestService.setStaleByUUID).toHaveBeenCalledTimes(2); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); + }); }); describe('deleteSubGroupFromGroup', () => { beforeEach(() => { + spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2'] + } as ObjectCacheEntry)); + spyOn(dataService, 'invalidateByHref'); service.deleteSubGroupFromGroup(GroupMock, GroupMock2).subscribe(); }); it('should send DeleteRequest to eperson/groups/group-id/subgroups/group-id endpoint', () => { const expected = new DeleteRequest(requestService.generateRequestId(), GroupMock.self + '/' + service.subgroupsEndpoint + '/' + GroupMock2.id); expect(requestService.send).toHaveBeenCalledWith(expected); }); + it('should invalidate the previous requests of the parent group\'', () => { + expect(objectCache.getByHref).toHaveBeenCalledWith(GroupMock._links.self.href); + expect(requestService.setStaleByUUID).toHaveBeenCalledTimes(2); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); + }); }); describe('addMemberToGroup', () => { beforeEach(() => { + spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2'] + } as ObjectCacheEntry)); + spyOn(dataService, 'invalidateByHref'); service.addMemberToGroup(GroupMock, EPersonMock2).subscribe(); }); it('should send PostRequest to eperson/groups/group-id/epersons endpoint with new eperson member in body', () => { @@ -143,20 +179,38 @@ describe('GroupDataService', () => { const expected = new PostRequest(requestService.generateRequestId(), GroupMock.self + '/' + service.ePersonsEndpoint, EPersonMock2.self, options); expect(requestService.send).toHaveBeenCalledWith(expected); }); + it('should invalidate the previous requests of the EPerson and the group', () => { + expect(objectCache.getByHref).toHaveBeenCalledWith(EPersonMock2._links.self.href); + expect(objectCache.getByHref).toHaveBeenCalledWith(GroupMock._links.self.href); + expect(requestService.setStaleByUUID).toHaveBeenCalledTimes(4); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); + }); }); describe('deleteMemberFromGroup', () => { beforeEach(() => { + spyOn(objectCache, 'getByHref').and.returnValue(observableOf({ + requestUUIDs: ['request1', 'request2'] + } as ObjectCacheEntry)); + spyOn(dataService, 'invalidateByHref'); service.deleteMemberFromGroup(GroupMock, EPersonMock).subscribe(); }); it('should send DeleteRequest to eperson/groups/group-id/epersons/eperson-id endpoint', () => { const expected = new DeleteRequest(requestService.generateRequestId(), GroupMock.self + '/' + service.ePersonsEndpoint + '/' + EPersonMock.id); expect(requestService.send).toHaveBeenCalledWith(expected); }); + it('should invalidate the previous requests of the EPerson and the group', () => { + expect(objectCache.getByHref).toHaveBeenCalledWith(EPersonMock._links.self.href); + expect(objectCache.getByHref).toHaveBeenCalledWith(GroupMock._links.self.href); + expect(requestService.setStaleByUUID).toHaveBeenCalledTimes(4); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request1'); + expect(requestService.setStaleByUUID).toHaveBeenCalledWith('request2'); + }); }); describe('editGroup', () => { - it('should dispatch a EDIT_GROUP action with the groupp to start editing', () => { + it('should dispatch a EDIT_GROUP action with the group to start editing', () => { service.editGroup(GroupMock); expect(store.dispatch).toHaveBeenCalledWith(new GroupRegistryEditGroupAction(GroupMock)); }); diff --git a/src/app/core/eperson/group-data.service.ts b/src/app/core/eperson/group-data.service.ts index 4467e26240..a994170281 100644 --- a/src/app/core/eperson/group-data.service.ts +++ b/src/app/core/eperson/group-data.service.ts @@ -2,8 +2,8 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { createSelector, select, Store } from '@ngrx/store'; -import { Observable } from 'rxjs'; -import { filter, map, take } from 'rxjs/operators'; +import { Observable, AsyncSubject, zip as observableZip } from 'rxjs'; +import { filter, map, take, switchMap } from 'rxjs/operators'; import { GroupRegistryCancelGroupAction, GroupRegistryEditGroupAction @@ -110,7 +110,8 @@ export class GroupDataService extends DataService { } /** - * Adds given subgroup as a subgroup to the given active group + * Adds given subgroup as a subgroup to the given active group and waits until the {@link activeGroup} and + * the {@link subgroup} are invalidated. * @param activeGroup Group we want to add subgroup to * @param subgroup Group we want to add as subgroup to activeGroup */ @@ -123,11 +124,46 @@ export class GroupDataService extends DataService { const postRequest = new PostRequest(requestId, activeGroup.self + '/' + this.subgroupsEndpoint, subgroup.self, options); this.requestService.send(postRequest); - return this.rdbService.buildFromRequestUUID(requestId); + const response$ = this.rdbService.buildFromRequestUUID(requestId); + + const invalidated$ = new AsyncSubject(); + response$.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return observableZip( + this.invalidateByHref(activeGroup._links.self.href), + this.requestService.setStaleByHrefSubstring(activeGroup._links.subgroups.href).pipe(take(1)), + ).pipe( + map((arr: boolean[]) => arr.every((b: boolean) => b === true)) + ); + } else { + return [true]; + } + }) + ).subscribe(() => { + invalidated$.next(true); + invalidated$.complete(); + }); + + return response$.pipe( + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return invalidated$.pipe( + filter((invalidated: boolean) => invalidated), + map(() => rd) + ); + } else { + return [rd]; + } + }) + ); } /** - * Deletes a given subgroup from the subgroups of the given active group + * Deletes a given subgroup from the subgroups of the given active group and waits until the {@link activeGroup} and + * the {@link subgroup} are invalidated. + * are invalidated. * @param activeGroup Group we want to delete subgroup from * @param subgroup Subgroup we want to delete from activeGroup */ @@ -136,11 +172,45 @@ export class GroupDataService extends DataService { const deleteRequest = new DeleteRequest(requestId, activeGroup.self + '/' + this.subgroupsEndpoint + '/' + subgroup.id); this.requestService.send(deleteRequest); - return this.rdbService.buildFromRequestUUID(requestId); + const response$ = this.rdbService.buildFromRequestUUID(requestId); + + const invalidated$ = new AsyncSubject(); + response$.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return observableZip( + this.invalidateByHref(activeGroup._links.self.href), + this.requestService.setStaleByHrefSubstring(activeGroup._links.subgroups.href).pipe(take(1)), + ).pipe( + map((arr: boolean[]) => arr.every((b: boolean) => b === true)) + ); + } else { + return [true]; + } + }) + ).subscribe(() => { + invalidated$.next(true); + invalidated$.complete(); + }); + + return response$.pipe( + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return invalidated$.pipe( + filter((invalidated: boolean) => invalidated), + map(() => rd) + ); + } else { + return [rd]; + } + }) + ); } /** - * Adds given ePerson as member to given group + * Adds given ePerson as member to a given group and invalidates the ePerson and waits until the {@link ePerson} and + * the {@link activeGroup} are invalidated. * @param activeGroup Group we want to add member to * @param ePerson EPerson we want to add as member to given activeGroup */ @@ -153,11 +223,47 @@ export class GroupDataService extends DataService { const postRequest = new PostRequest(requestId, activeGroup.self + '/' + this.ePersonsEndpoint, ePerson.self, options); this.requestService.send(postRequest); - return this.rdbService.buildFromRequestUUID(requestId); + const response$ = this.rdbService.buildFromRequestUUID(requestId); + + const invalidated$ = new AsyncSubject(); + response$.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return observableZip( + this.invalidateByHref(ePerson._links.self.href), + this.invalidateByHref(activeGroup._links.self.href), + this.requestService.setStaleByHrefSubstring(ePerson._links.groups.href).pipe(take(1)), + this.requestService.setStaleByHrefSubstring(activeGroup._links.epersons.href).pipe(take(1)), + ).pipe( + map((arr: boolean[]) => arr.every((b: boolean) => b === true)) + ); + } else { + return [true]; + } + }) + ).subscribe(() => { + invalidated$.next(true); + invalidated$.complete(); + }); + + return response$.pipe( + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return invalidated$.pipe( + filter((invalidated: boolean) => invalidated), + map(() => rd) + ); + } else { + return [rd]; + } + }) + ); } /** - * Deletes a given ePerson from the members of the given active group + * Deletes a given ePerson from the members of the given active group and waits until the {@link ePerson} and the + * {@link activeGroup} are invalidated. * @param activeGroup Group we want to delete member from * @param ePerson EPerson we want to delete from members of given activeGroup */ @@ -166,7 +272,42 @@ export class GroupDataService extends DataService { const deleteRequest = new DeleteRequest(requestId, activeGroup.self + '/' + this.ePersonsEndpoint + '/' + ePerson.id); this.requestService.send(deleteRequest); - return this.rdbService.buildFromRequestUUID(requestId); + const response$ = this.rdbService.buildFromRequestUUID(requestId); + + const invalidated$ = new AsyncSubject(); + response$.pipe( + getFirstCompletedRemoteData(), + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return observableZip( + this.invalidateByHref(ePerson._links.self.href), + this.invalidateByHref(activeGroup._links.self.href), + this.requestService.setStaleByHrefSubstring(ePerson._links.groups.href).pipe(take(1)), + this.requestService.setStaleByHrefSubstring(activeGroup._links.epersons.href).pipe(take(1)), + ).pipe( + map((arr: boolean[]) => arr.every((b: boolean) => b === true)) + ); + } else { + return [true]; + } + }) + ).subscribe(() => { + invalidated$.next(true); + invalidated$.complete(); + }); + + return response$.pipe( + switchMap((rd: RemoteData) => { + if (rd.hasSucceeded) { + return invalidated$.pipe( + filter((invalidated: boolean) => invalidated), + map(() => rd) + ); + } else { + return [rd]; + } + }) + ); } /** @@ -262,7 +403,7 @@ export class GroupDataService extends DataService { * @param role The name of the role for which to create a group * @param link The REST endpoint to create the group */ - createComcolGroup(dso: Community|Collection, role: string, link: string): Observable> { + createComcolGroup(dso: Community | Collection, role: string, link: string): Observable> { const requestId = this.requestService.generateRequestId(); const group = Object.assign(new Group(), { diff --git a/src/app/core/shared/distinct-next.ts b/src/app/core/shared/distinct-next.ts new file mode 100644 index 0000000000..6c629867a4 --- /dev/null +++ b/src/app/core/shared/distinct-next.ts @@ -0,0 +1,22 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ + +import { BehaviorSubject } from 'rxjs'; + +/** + * Use nextValue to update a given BehaviorSubject, only if it differs from its current value + * + * @param bs a BehaviorSubject + * @param nextValue the next value for that BehaviorSubject + * @protected + */ +export function distinctNext(bs: BehaviorSubject, nextValue: T): void { + if (bs.getValue() !== nextValue) { + bs.next(nextValue); + } +} diff --git a/src/app/init.service.spec.ts b/src/app/init.service.spec.ts new file mode 100644 index 0000000000..2c9edfd4e0 --- /dev/null +++ b/src/app/init.service.spec.ts @@ -0,0 +1,187 @@ +import { InitService } from './init.service'; +import { APP_CONFIG } from 'src/config/app-config.interface'; +import { APP_INITIALIZER, Injectable } from '@angular/core'; +import { inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { MetadataService } from './core/metadata/metadata.service'; +import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; +import { CommonModule } from '@angular/common'; +import { StoreModule } from '@ngrx/store'; +import { authReducer } from './core/auth/auth.reducer'; +import { storeModuleConfig } from './app.reducer'; +import { AngularticsProviderMock } from './shared/mocks/angulartics-provider.service.mock'; +import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; +import { AuthService } from './core/auth/auth.service'; +import { AuthServiceMock } from './shared/mocks/auth.service.mock'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterMock } from './shared/mocks/router.mock'; +import { MockActivatedRoute } from './shared/mocks/active-router.mock'; +import { MenuService } from './shared/menu/menu.service'; +import { LocaleService } from './core/locale/locale.service'; +import { environment } from '../environments/environment'; +import { provideMockStore } from '@ngrx/store/testing'; +import { AppComponent } from './app.component'; +import { RouteService } from './core/services/route.service'; +import { getMockLocaleService } from './app.component.spec'; +import { MenuServiceStub } from './shared/testing/menu-service.stub'; +import { CorrelationIdService } from './correlation-id/correlation-id.service'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from './shared/mocks/translate-loader.mock'; +import { ThemeService } from './shared/theme-support/theme.service'; +import { getMockThemeService } from './shared/mocks/theme-service.mock'; +import objectContaining = jasmine.objectContaining; +import createSpyObj = jasmine.createSpyObj; +import SpyObj = jasmine.SpyObj; + +let spy: SpyObj; + +@Injectable() +export class ConcreteInitServiceMock extends InitService { + protected static resolveAppConfig() { + spy.resolveAppConfig(); + } + + protected init(): () => Promise { + spy.init(); + return async () => true; + } +} + +const initialState = { + core: { + auth: { + loading: false, + blocking: true, + } + } +}; + + +describe('InitService', () => { + describe('providers', () => { + beforeEach(() => { + spy = createSpyObj('ConcreteInitServiceMock', { + resolveAppConfig: null, + init: null, + }); + }); + + it('should throw error when called on abstract InitService', () => { + expect(() => InitService.providers()).toThrow(); + }); + + it('should correctly set up provider dependencies', () => { + const providers = ConcreteInitServiceMock.providers(); + + expect(providers).toContain(objectContaining({ + provide: InitService, + useClass: ConcreteInitServiceMock + })); + + expect(providers).toContain(objectContaining({ + provide: APP_CONFIG, + })); + + expect(providers).toContain(objectContaining({ + provide: APP_INITIALIZER, + deps: [ InitService ], + multi: true, + })); + }); + + it('should call resolveAppConfig() in APP_CONFIG factory', () => { + const factory = ( + ConcreteInitServiceMock.providers() + .find((p: any) => p.provide === APP_CONFIG) as any + ).useFactory; + + // this factory is called _before_ InitService is instantiated + factory(); + expect(spy.resolveAppConfig).toHaveBeenCalled(); + expect(spy.init).not.toHaveBeenCalled(); + }); + + it('should defer to init() in APP_INITIALIZER factory', () => { + const factory = ( + ConcreteInitServiceMock.providers() + .find((p: any) => p.provide === APP_INITIALIZER) as any + ).useFactory; + + // we don't care about the dependencies here + // @ts-ignore + const instance = new ConcreteInitServiceMock(null, null, null); + + // provider ensures that the right concrete instance is passed to the factory + factory(instance); + expect(spy.resolveAppConfig).not.toHaveBeenCalled(); + expect(spy.init).toHaveBeenCalled(); + }); + }); + + describe('common initialization steps', () => { + let correlationIdServiceSpy; + let dspaceTransferStateSpy; + let transferStateSpy; + let metadataServiceSpy; + let breadcrumbsServiceSpy; + + beforeEach(waitForAsync(() => { + correlationIdServiceSpy = jasmine.createSpyObj('correlationIdServiceSpy', [ + 'initCorrelationId', + ]); + dspaceTransferStateSpy = jasmine.createSpyObj('dspaceTransferStateSpy', [ + 'transfer', + ]); + transferStateSpy = jasmine.createSpyObj('dspaceTransferStateSpy', [ + 'get', 'hasKey' + ]); + breadcrumbsServiceSpy = jasmine.createSpyObj('breadcrumbsServiceSpy', [ + 'listenForRouteChanges', + ]); + metadataServiceSpy = jasmine.createSpyObj('metadataService', [ + 'listenForRouteChange', + ]); + + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + CommonModule, + StoreModule.forRoot(authReducer, storeModuleConfig), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + providers: [ + { provide: InitService, useClass: ConcreteInitServiceMock }, + { provide: CorrelationIdService, useValue: correlationIdServiceSpy }, + { provide: APP_CONFIG, useValue: environment }, + { provide: LocaleService, useValue: getMockLocaleService() }, + { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, + { provide: MetadataService, useValue: metadataServiceSpy }, + { provide: BreadcrumbsService, useValue: breadcrumbsServiceSpy }, + { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: Router, useValue: new RouterMock() }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: MenuService, useValue: new MenuServiceStub() }, + { provide: ThemeService, useValue: getMockThemeService() }, + provideMockStore({ initialState }), + AppComponent, + RouteService, + ] + }); + })); + + describe('initRouteListeners', () => { + it('should call listenForRouteChanges', inject([InitService], (service) => { + // @ts-ignore + service.initRouteListeners(); + expect(metadataServiceSpy.listenForRouteChange).toHaveBeenCalledTimes(1); + expect(breadcrumbsServiceSpy.listenForRouteChanges).toHaveBeenCalledTimes(1); + })); + }); + }); +}); + diff --git a/src/app/init.service.ts b/src/app/init.service.ts new file mode 100644 index 0000000000..d91f6b2dd9 --- /dev/null +++ b/src/app/init.service.ts @@ -0,0 +1,189 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { Store } from '@ngrx/store'; +import { CheckAuthenticationTokenAction } from './core/auth/auth.actions'; +import { CorrelationIdService } from './correlation-id/correlation-id.service'; +import { APP_INITIALIZER, Inject, Provider, Type } from '@angular/core'; +import { makeStateKey, TransferState } from '@angular/platform-browser'; +import { APP_CONFIG, AppConfig } from '../config/app-config.interface'; +import { environment } from '../environments/environment'; +import { AppState } from './app.reducer'; +import { isEqual } from 'lodash'; +import { TranslateService } from '@ngx-translate/core'; +import { LocaleService } from './core/locale/locale.service'; +import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; +import { MetadataService } from './core/metadata/metadata.service'; +import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service'; +import { ThemeService } from './shared/theme-support/theme.service'; + +/** + * Performs the initialization of the app. + * + * Should be extended to implement server- & browser-specific functionality. + * Initialization steps shared between the server and brower implementations + * can be included in this class. + * + * Note that the service cannot (indirectly) depend on injection tokens that are only available _after_ APP_INITIALIZER. + * For example, NgbModal depends on ApplicationRef and can therefore not be used during initialization. + */ +export abstract class InitService { + /** + * The state transfer key to use for the NgRx store state + * @protected + */ + protected static NGRX_STATE = makeStateKey('NGRX_STATE'); + + protected constructor( + protected store: Store, + protected correlationIdService: CorrelationIdService, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + protected translate: TranslateService, + protected localeService: LocaleService, + protected angulartics2DSpace: Angulartics2DSpace, + protected metadata: MetadataService, + protected breadcrumbsService: BreadcrumbsService, + protected themeService: ThemeService, + ) { + } + + /** + * The initialization providers to use in `*AppModule` + * - this concrete {@link InitService} + * - {@link APP_CONFIG} with optional pre-initialization hook + * - {@link APP_INITIALIZER} + *
+ * Should only be called on concrete subclasses of InitService for the initialization hooks to work + */ + public static providers(): Provider[] { + if (!InitService.isPrototypeOf(this)) { + throw new Error( + 'Initalization providers should only be generated from concrete subclasses of InitService' + ); + } + return [ + { + provide: InitService, + useClass: this as unknown as Type, + }, + { + provide: APP_CONFIG, + useFactory: (transferState: TransferState) => { + this.resolveAppConfig(transferState); + return environment; + }, + deps: [ TransferState ] + }, + { + provide: APP_INITIALIZER, + useFactory: (initService: InitService) => initService.init(), + deps: [ InitService ], + multi: true, + }, + ]; + } + + /** + * Optional pre-initialization method to ensure that {@link APP_CONFIG} is fully resolved before {@link init} is called. + * + * For example, Router depends on APP_BASE_HREF, which in turn depends on APP_CONFIG. + * In production mode, APP_CONFIG is resolved from the TransferState when the app is initialized. + * If we want to use Router within APP_INITIALIZER, we have to make sure APP_BASE_HREF is resolved beforehand. + * In this case that means that we must transfer the configuration from the SSR state during pre-initialization. + * @protected + */ + protected static resolveAppConfig( + transferState: TransferState + ): void { + // overriden in subclasses if applicable + } + + /** + * Main initialization method. + * @protected + */ + protected abstract init(): () => Promise; + + // Common initialization steps + + /** + * Dispatch a {@link CheckAuthenticationTokenAction} to start off the chain of + * actions used to determine whether a user is already logged in. + * @protected + */ + protected checkAuthenticationToken(): void { + this.store.dispatch(new CheckAuthenticationTokenAction()); + } + + /** + * Initialize the correlation ID (from cookie, NgRx store or random) + * @protected + */ + protected initCorrelationId(): void { + this.correlationIdService.initCorrelationId(); + } + + /** + * Make sure the {@link environment} matches {@link APP_CONFIG} and print + * some information about it to the console + * @protected + */ + protected checkEnvironment(): void { + if (!isEqual(environment, this.appConfig)) { + throw new Error('environment does not match app config!'); + } + + if (environment.debug) { + console.info(environment); + } + + const env: string = environment.production ? 'Production' : 'Development'; + const color: string = environment.production ? 'red' : 'green'; + console.info(`Environment: %c${env}`, `color: ${color}; font-weight: bold;`); + } + + /** + * Initialize internationalization services + * - Specify the active languages + * - Set the current locale + * @protected + */ + protected initI18n(): void { + // Load all the languages that are defined as active from the config file + this.translate.addLangs( + environment.languages + .filter((LangConfig) => LangConfig.active === true) + .map((a) => a.code) + ); + + // Load the default language from the config file + // translate.setDefaultLang(environment.defaultLanguage); + + this.localeService.setCurrentLanguageCode(); + } + + /** + * Initialize Angulartics + * @protected + */ + protected initAngulartics(): void { + this.angulartics2DSpace.startTracking(); + } + + /** + * Start route-listening subscriptions + * - {@link MetadataService.listenForRouteChange} + * - {@link BreadcrumbsService.listenForRouteChanges} + * - {@link ThemeService.listenForRouteChanges} + * @protected + */ + protected initRouteListeners(): void { + this.metadata.listenForRouteChange(); + this.breadcrumbsService.listenForRouteChanges(); + this.themeService.listenForRouteChanges(); + } +} diff --git a/src/app/shared/mocks/object-cache.service.mock.ts b/src/app/shared/mocks/object-cache.service.mock.ts index 144c82fe16..515c2abe86 100644 --- a/src/app/shared/mocks/object-cache.service.mock.ts +++ b/src/app/shared/mocks/object-cache.service.mock.ts @@ -11,7 +11,8 @@ export function getMockObjectCacheService(): ObjectCacheService { 'getRequestHrefByUUID', 'getList', 'hasByUUID', - 'hasByHref' + 'hasByHref', + 'getRequestUUIDBySelfLink', ]); } diff --git a/src/app/shared/mocks/theme-service.mock.ts b/src/app/shared/mocks/theme-service.mock.ts index 058ba993bc..e3c2960e51 100644 --- a/src/app/shared/mocks/theme-service.mock.ts +++ b/src/app/shared/mocks/theme-service.mock.ts @@ -8,6 +8,7 @@ export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]): getThemeName: themeName, getThemeName$: observableOf(themeName), getThemeConfigFor: undefined, + listenForRouteChanges: undefined, }); if (isNotEmpty(themes)) { diff --git a/src/app/shared/testing/data-service.stub.ts b/src/app/shared/testing/data-service.stub.ts new file mode 100644 index 0000000000..62334a95e0 --- /dev/null +++ b/src/app/shared/testing/data-service.stub.ts @@ -0,0 +1,9 @@ +import { Observable, of as observableOf } from 'rxjs'; + +export class DataServiceStub { + + invalidateByHref(_href: string): Observable { + return observableOf(true); + } + +} diff --git a/src/app/shared/theme-support/theme.service.spec.ts b/src/app/shared/theme-support/theme.service.spec.ts index 84043369c0..43b5964b8c 100644 --- a/src/app/shared/theme-support/theme.service.spec.ts +++ b/src/app/shared/theme-support/theme.service.spec.ts @@ -2,7 +2,7 @@ import { of as observableOf } from 'rxjs'; import { TestBed } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; import { LinkService } from '../../core/cache/builders/link.service'; -import { cold, hot } from 'jasmine-marbles'; +import { hot } from 'jasmine-marbles'; import { SetThemeAction } from './theme.actions'; import { Theme } from '../../../config/theme.model'; import { provideMockStore } from '@ngrx/store/testing'; @@ -21,7 +21,9 @@ import { import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; import { ThemeService } from './theme.service'; import { ROUTER_NAVIGATED } from '@ngrx/router-store'; -import { ActivatedRouteSnapshot } from '@angular/router'; +import { ActivatedRouteSnapshot, Router } from '@angular/router'; +import { CommonModule, DOCUMENT } from '@angular/common'; +import { RouterMock } from '../mocks/router.mock'; /** * LinkService able to mock recursively resolving DSO parent links @@ -84,12 +86,16 @@ describe('ThemeService', () => { findById: () => createSuccessfulRemoteDataObject$(mockCommunity) }; TestBed.configureTestingModule({ + imports: [ + CommonModule, + ], providers: [ ThemeService, { provide: LinkService, useValue: linkService }, provideMockStore({ initialState }), provideMockActions(() => mockActions), - { provide: DSpaceObjectDataService, useValue: mockDsoService } + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new RouterMock() }, ] }); @@ -367,4 +373,49 @@ describe('ThemeService', () => { }); }); }); + + describe('listenForThemeChanges', () => { + let document; + let headSpy; + + beforeEach(() => { + const mockDsoService = { + findById: () => createSuccessfulRemoteDataObject$(mockCommunity) + }; + + TestBed.configureTestingModule({ + imports: [ + CommonModule, + ], + providers: [ + ThemeService, + { provide: LinkService, useValue: linkService }, + provideMockStore({ initialState }), + { provide: DSpaceObjectDataService, useValue: mockDsoService }, + { provide: Router, useValue: new RouterMock() }, + ] + }); + + document = TestBed.inject(DOCUMENT); + headSpy = jasmine.createSpyObj('head', ['appendChild', 'getElementsByClassName']); + headSpy.getElementsByClassName.and.returnValue([]); + spyOn(document, 'getElementsByTagName').and.returnValue([headSpy]); + + themeService = TestBed.inject(ThemeService); + spyOn(themeService, 'getThemeName').and.returnValue('custom'); + spyOn(themeService, 'getThemeName$').and.returnValue(observableOf('custom')); + }); + + it('should append a link element with the correct attributes to the head element', () => { + themeService.listenForThemeChanges(true); + + const link = document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('type', 'text/css'); + link.setAttribute('class', 'theme-css'); + link.setAttribute('href', '/custom-theme.css'); + + expect(headSpy.appendChild).toHaveBeenCalledWith(link); + }); + }); }); diff --git a/src/app/shared/theme-support/theme.service.ts b/src/app/shared/theme-support/theme.service.ts index 4a4f6ae986..6d9bffc44e 100644 --- a/src/app/shared/theme-support/theme.service.ts +++ b/src/app/shared/theme-support/theme.service.ts @@ -1,10 +1,10 @@ -import { Injectable, Inject } from '@angular/core'; -import { Store, createFeatureSelector, createSelector, select } from '@ngrx/store'; -import { EMPTY, Observable, of as observableOf } from 'rxjs'; +import { Inject, Injectable } from '@angular/core'; +import { createFeatureSelector, createSelector, select, Store } from '@ngrx/store'; +import { BehaviorSubject, EMPTY, Observable, of as observableOf } from 'rxjs'; import { ThemeState } from './theme.reducer'; import { SetThemeAction, ThemeActionTypes } from './theme.actions'; import { expand, filter, map, switchMap, take, toArray } from 'rxjs/operators'; -import { hasValue, isNotEmpty } from '../empty.util'; +import { hasNoValue, hasValue, isNotEmpty } from '../empty.util'; import { RemoteData } from '../../core/data/remote-data'; import { DSpaceObject } from '../../core/shared/dspace-object.model'; import { @@ -12,14 +12,18 @@ import { getFirstSucceededRemoteData, getRemoteDataPayload } from '../../core/shared/operators'; -import { Theme, ThemeConfig, themeFactory } from '../../../config/theme.model'; +import { HeadTagConfig, Theme, ThemeConfig, themeFactory } from '../../../config/theme.model'; import { NO_OP_ACTION_TYPE, NoOpAction } from '../ngrx/no-op.action'; import { followLink } from '../utils/follow-link-config.model'; import { LinkService } from '../../core/cache/builders/link.service'; import { environment } from '../../../environments/environment'; import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service'; -import { ActivatedRouteSnapshot } from '@angular/router'; +import { ActivatedRouteSnapshot, ResolveEnd, Router } from '@angular/router'; import { GET_THEME_CONFIG_FOR_FACTORY } from '../object-collection/shared/listable-object/listable-object.decorator'; +import { distinctNext } from 'src/app/core/shared/distinct-next'; +import { DOCUMENT } from '@angular/common'; +import { getDefaultThemeConfig } from '../../../config/config.util'; +import { BASE_THEME_NAME } from './theme.constants'; export const themeStateSelector = createFeatureSelector('theme'); @@ -42,11 +46,16 @@ export class ThemeService { */ hasDynamicTheme: boolean; + private _isThemeLoading$ = new BehaviorSubject(false); + private _isThemeCSSLoading$ = new BehaviorSubject(false); + constructor( private store: Store, private linkService: LinkService, private dSpaceObjectDataService: DSpaceObjectDataService, - @Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig + @Inject(GET_THEME_CONFIG_FOR_FACTORY) private gtcf: (str) => ThemeConfig, + private router: Router, + @Inject(DOCUMENT) private document: any, ) { // Create objects from the theme configs in the environment file this.themes = environment.themes.map((themeConfig: ThemeConfig) => themeFactory(themeConfig)); @@ -57,10 +66,17 @@ export class ThemeService { ); } + /** + * Set the current theme + * @param newName + */ setTheme(newName: string) { this.store.dispatch(new SetThemeAction(newName)); } + /** + * The name of the current theme (synchronous) + */ getThemeName(): string { let currentTheme: string; this.store.pipe( @@ -72,12 +88,205 @@ export class ThemeService { return currentTheme; } + /** + * The name of the current theme (asynchronous, tracks changes) + */ getThemeName$(): Observable { return this.store.pipe( select(currentThemeSelector) ); } + /** + * Whether the theme is currently loading + */ + get isThemeLoading$(): Observable { + return this._isThemeLoading$; + } + + /** + * Every time the theme is changed + * - if the theme name is valid, load it (CSS + tags) + * - otherwise fall back to {@link getDefaultThemeConfig} or {@link BASE_THEME_NAME} + * Should be called when initializing the app. + * @param isBrowser + */ + listenForThemeChanges(isBrowser: boolean): void { + this.getThemeName$().subscribe((themeName: string) => { + if (isBrowser) { + // the theme css will never download server side, so this should only happen on the browser + distinctNext(this._isThemeCSSLoading$, true); + } + if (hasValue(themeName)) { + this.loadGlobalThemeConfig(themeName); + } else { + const defaultThemeConfig = getDefaultThemeConfig(); + if (hasValue(defaultThemeConfig)) { + this.loadGlobalThemeConfig(defaultThemeConfig.name); + } else { + this.loadGlobalThemeConfig(BASE_THEME_NAME); + } + } + }); + } + + /** + * For every resolved route, check if it matches a dynamic theme. If it does, load that theme. + * Should be called when initializing the app. + */ + listenForRouteChanges(): void { + this.router.events.pipe( + filter(event => event instanceof ResolveEnd), + switchMap((event: ResolveEnd) => this.updateThemeOnRouteChange$(event.urlAfterRedirects, event.state.root)), + switchMap((changed) => { + if (changed) { + return this._isThemeCSSLoading$; + } else { + return [false]; + } + }) + ).subscribe((changed) => { + distinctNext(this._isThemeLoading$, changed); + }); + } + + /** + * Load a theme's configuration + * - CSS + * - tags + * @param themeName + * @private + */ + private loadGlobalThemeConfig(themeName: string): void { + this.setThemeCss(themeName); + this.setHeadTags(themeName); + } + + /** + * Update the theme css file in + * + * @param themeName The name of the new theme + * @private + */ + private setThemeCss(themeName: string): void { + const head = this.document.getElementsByTagName('head')[0]; + if (hasNoValue(head)) { + return; + } + + // Array.from to ensure we end up with an array, not an HTMLCollection, which would be + // automatically updated if we add nodes later + const currentThemeLinks = Array.from(head.getElementsByClassName('theme-css')); + const link = this.document.createElement('link'); + link.setAttribute('rel', 'stylesheet'); + link.setAttribute('type', 'text/css'); + link.setAttribute('class', 'theme-css'); + link.setAttribute('href', `/${encodeURIComponent(themeName)}-theme.css`); + // wait for the new css to download before removing the old one to prevent a + // flash of unstyled content + link.onload = () => { + if (isNotEmpty(currentThemeLinks)) { + currentThemeLinks.forEach((currentThemeLink: any) => { + if (hasValue(currentThemeLink)) { + currentThemeLink.remove(); + } + }); + } + // the fact that this callback is used, proves we're on the browser. + distinctNext(this._isThemeCSSLoading$, false); + }; + head.appendChild(link); + } + + /** + * Update the page to add a theme's tags + * @param themeName the theme in question + * @private + */ + private setHeadTags(themeName: string): void { + const head = this.document.getElementsByTagName('head')[0]; + if (hasNoValue(head)) { + return; + } + + // clear head tags + const currentHeadTags = Array.from(head.getElementsByClassName('theme-head-tag')); + if (hasValue(currentHeadTags)) { + currentHeadTags.forEach((currentHeadTag: any) => currentHeadTag.remove()); + } + + // create new head tags (not yet added to DOM) + const headTagFragment = this.document.createDocumentFragment(); + this.createHeadTags(themeName) + .forEach(newHeadTag => headTagFragment.appendChild(newHeadTag)); + + // add new head tags to DOM + head.appendChild(headTagFragment); + } + + /** + * Create HTML elements for a theme's tags + * (including those defined in the parent theme, if applicable) + * @param themeName the theme in question + * @private + */ + private createHeadTags(themeName: string): HTMLElement[] { + const themeConfig = this.getThemeConfigFor(themeName); + const headTagConfigs = themeConfig?.headTags; + + if (hasNoValue(headTagConfigs)) { + const parentThemeName = themeConfig?.extends; + if (hasValue(parentThemeName)) { + // inherit the head tags of the parent theme + return this.createHeadTags(parentThemeName); + } + const defaultThemeConfig = getDefaultThemeConfig(); + const defaultThemeName = defaultThemeConfig.name; + if ( + hasNoValue(defaultThemeName) || + themeName === defaultThemeName || + themeName === BASE_THEME_NAME + ) { + // last resort, use fallback favicon.ico + return [ + this.createHeadTag({ + 'tagName': 'link', + 'attributes': { + 'rel': 'icon', + 'href': 'assets/images/favicon.ico', + 'sizes': 'any', + } + }) + ]; + } + + // inherit the head tags of the default theme + return this.createHeadTags(defaultThemeConfig.name); + } + + return headTagConfigs.map(this.createHeadTag.bind(this)); + } + + /** + * Create a single tag element + * @param headTagConfig the configuration for this tag + * @private + */ + private createHeadTag(headTagConfig: HeadTagConfig): HTMLElement { + const tag = this.document.createElement(headTagConfig.tagName); + + if (hasValue(headTagConfig.attributes)) { + Object.entries(headTagConfig.attributes) + .forEach(([key, value]) => tag.setAttribute(key, value)); + } + + // 'class' attribute should always be 'theme-head-tag' for removal + tag.setAttribute('class', 'theme-head-tag'); + + return tag; + } + + /** * Determine whether or not the theme needs to change depending on the current route's URL and snapshot data * If the snapshot contains a dso, this will be used to match a theme diff --git a/src/config/app-config.interface.ts b/src/config/app-config.interface.ts index f9d38d9b45..cd9ef103ae 100644 --- a/src/config/app-config.interface.ts +++ b/src/config/app-config.interface.ts @@ -40,6 +40,10 @@ interface AppConfig extends Config { info: InfoConfig; } +/** + * Injection token for the app configuration. + * Provided in {@link InitService.providers}. + */ const APP_CONFIG = new InjectionToken('APP_CONFIG'); const APP_CONFIG_STATE = makeStateKey('APP_CONFIG_STATE'); diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index dc3be0de30..404c624a8a 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -1,6 +1,6 @@ import { HttpClient, HttpClientModule } from '@angular/common/http'; -import { APP_INITIALIZER, NgModule } from '@angular/core'; -import { BrowserModule, makeStateKey, TransferState } from '@angular/platform-browser'; +import { NgModule } from '@angular/core'; +import { BrowserModule, BrowserTransferStateModule, makeStateKey, TransferState } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { REQUEST } from '@nguniversal/express-engine/tokens'; @@ -12,8 +12,6 @@ import { IdlePreloadModule } from 'angular-idle-preload'; import { AppComponent } from '../../app/app.component'; import { AppModule } from '../../app/app.module'; -import { DSpaceBrowserTransferStateModule } from '../transfer-state/dspace-browser-transfer-state.module'; -import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service'; import { ClientCookieService } from '../../app/core/services/client-cookie.service'; import { CookieService } from '../../app/core/services/cookie.service'; import { AuthService } from '../../app/core/auth/auth.service'; @@ -23,21 +21,12 @@ import { StatisticsModule } from '../../app/statistics/statistics.module'; import { BrowserKlaroService } from '../../app/shared/cookies/browser-klaro.service'; import { KlaroService } from '../../app/shared/cookies/klaro.service'; import { HardRedirectService } from '../../app/core/services/hard-redirect.service'; -import { - BrowserHardRedirectService, - locationProvider, - LocationToken -} from '../../app/core/services/browser-hard-redirect.service'; +import { BrowserHardRedirectService, locationProvider, LocationToken } from '../../app/core/services/browser-hard-redirect.service'; import { LocaleService } from '../../app/core/locale/locale.service'; import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { BrowserAuthRequestService } from '../../app/core/auth/browser-auth-request.service'; -import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface'; -import { DefaultAppConfig } from '../../config/default-app-config'; -import { extendEnvironmentWithAppConfig } from '../../config/config.util'; -import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; - -import { environment } from '../../environments/environment'; +import { BrowserInitService } from './browser-init.service'; export const REQ_KEY = makeStateKey('req'); @@ -61,7 +50,7 @@ export function getRequest(transferState: TransferState): any { StatisticsModule.forRoot(), Angulartics2RouterlessModule.forRoot(), BrowserAnimationsModule, - DSpaceBrowserTransferStateModule, + BrowserTransferStateModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, @@ -72,27 +61,7 @@ export function getRequest(transferState: TransferState): any { AppModule ], providers: [ - { - provide: APP_INITIALIZER, - useFactory: ( - transferState: TransferState, - dspaceTransferState: DSpaceTransferState, - correlationIdService: CorrelationIdService - ) => { - if (transferState.hasKey(APP_CONFIG_STATE)) { - const appConfig = transferState.get(APP_CONFIG_STATE, new DefaultAppConfig()); - // extend environment with app config for browser - extendEnvironmentWithAppConfig(environment, appConfig); - } - return () => - dspaceTransferState.transfer().then((b: boolean) => { - correlationIdService.initCorrelationId(); - return b; - }); - }, - deps: [TransferState, DSpaceTransferState, CorrelationIdService], - multi: true - }, + ...BrowserInitService.providers(), { provide: REQUEST, useFactory: getRequest, diff --git a/src/modules/app/browser-init.service.spec.ts b/src/modules/app/browser-init.service.spec.ts new file mode 100644 index 0000000000..05da7d9d36 --- /dev/null +++ b/src/modules/app/browser-init.service.spec.ts @@ -0,0 +1,155 @@ +import { InitService } from '../../app/init.service'; +import { APP_CONFIG } from 'src/config/app-config.interface'; +import { inject, TestBed, waitForAsync } from '@angular/core/testing'; +import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; +import { MetadataService } from '../../app/core/metadata/metadata.service'; +import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; +import { CommonModule } from '@angular/common'; +import { Store, StoreModule } from '@ngrx/store'; +import { authReducer } from '../../app/core/auth/auth.reducer'; +import { storeModuleConfig } from '../../app/app.reducer'; +import { AngularticsProviderMock } from '../../app/shared/mocks/angulartics-provider.service.mock'; +import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { AuthService } from '../../app/core/auth/auth.service'; +import { AuthServiceMock } from '../../app/shared/mocks/auth.service.mock'; +import { ActivatedRoute, Router } from '@angular/router'; +import { RouterMock } from '../../app/shared/mocks/router.mock'; +import { MockActivatedRoute } from '../../app/shared/mocks/active-router.mock'; +import { MenuService } from '../../app/shared/menu/menu.service'; +import { LocaleService } from '../../app/core/locale/locale.service'; +import { environment } from '../../environments/environment'; +import { provideMockStore } from '@ngrx/store/testing'; +import { AppComponent } from '../../app/app.component'; +import { RouteService } from '../../app/core/services/route.service'; +import { getMockLocaleService } from '../../app/app.component.spec'; +import { MenuServiceStub } from '../../app/shared/testing/menu-service.stub'; +import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; +import { KlaroService } from '../../app/shared/cookies/klaro.service'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { TranslateLoaderMock } from '../../app/shared/mocks/translate-loader.mock'; +import { getTestScheduler } from 'jasmine-marbles'; +import { ThemeService } from '../../app/shared/theme-support/theme.service'; +import { getMockThemeService } from '../../app/shared/mocks/theme-service.mock'; +import { BrowserInitService } from './browser-init.service'; +import { TransferState } from '@angular/platform-browser'; + +const initialState = { + core: { + auth: { + loading: false, + blocking: true, + } + } +}; + +describe('BrowserInitService', () => { + describe('browser-specific initialization steps', () => { + let correlationIdServiceSpy; + let dspaceTransferStateSpy; + let transferStateSpy; + let metadataServiceSpy; + let breadcrumbsServiceSpy; + let klaroServiceSpy; + let googleAnalyticsSpy; + + beforeEach(waitForAsync(() => { + correlationIdServiceSpy = jasmine.createSpyObj('correlationIdServiceSpy', [ + 'initCorrelationId', + ]); + dspaceTransferStateSpy = jasmine.createSpyObj('dspaceTransferStateSpy', [ + 'transfer', + ]); + transferStateSpy = jasmine.createSpyObj('dspaceTransferStateSpy', [ + 'get', 'hasKey' + ]); + breadcrumbsServiceSpy = jasmine.createSpyObj('breadcrumbsServiceSpy', [ + 'listenForRouteChanges', + ]); + metadataServiceSpy = jasmine.createSpyObj('metadataService', [ + 'listenForRouteChange', + ]); + klaroServiceSpy = jasmine.createSpyObj('klaroServiceSpy', [ + 'initialize', + ]); + googleAnalyticsSpy = jasmine.createSpyObj('googleAnalyticsService', [ + 'addTrackingIdToPage', + ]); + + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + CommonModule, + StoreModule.forRoot(authReducer, storeModuleConfig), + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + providers: [ + { provide: InitService, useClass: BrowserInitService }, + { provide: CorrelationIdService, useValue: correlationIdServiceSpy }, + { provide: APP_CONFIG, useValue: environment }, + { provide: LocaleService, useValue: getMockLocaleService() }, + { provide: Angulartics2DSpace, useValue: new AngularticsProviderMock() }, + { provide: MetadataService, useValue: metadataServiceSpy }, + { provide: BreadcrumbsService, useValue: breadcrumbsServiceSpy }, + { provide: AuthService, useValue: new AuthServiceMock() }, + { provide: Router, useValue: new RouterMock() }, + { provide: ActivatedRoute, useValue: new MockActivatedRoute() }, + { provide: MenuService, useValue: new MenuServiceStub() }, + { provide: KlaroService, useValue: klaroServiceSpy }, + { provide: GoogleAnalyticsService, useValue: googleAnalyticsSpy }, + { provide: ThemeService, useValue: getMockThemeService() }, + provideMockStore({ initialState }), + AppComponent, + RouteService, + { provide: TransferState, useValue: undefined }, + ] + }); + })); + + describe('initGoogleÀnalytics', () => { + it('should call googleAnalyticsService.addTrackingIdToPage()', inject([InitService], (service) => { + // @ts-ignore + service.initGoogleAnalytics(); + expect(googleAnalyticsSpy.addTrackingIdToPage).toHaveBeenCalledTimes(1); + })); + }); + + describe('initKlaro', () => { + const BLOCKING = { + t: { core: { auth: { blocking: true } } }, + f: { core: { auth: { blocking: false } } }, + }; + + it('should not initialize Klaro while auth is blocking', () => { + getTestScheduler().run(({ cold, flush}) => { + TestBed.overrideProvider(Store, { useValue: cold('t--t--t--', BLOCKING) }); + const service = TestBed.inject(InitService); + + // @ts-ignore + service.initKlaro(); + flush(); + expect(klaroServiceSpy.initialize).not.toHaveBeenCalled(); + }); + }); + + + it('should only initialize Klaro the first time auth is unblocked', () => { + getTestScheduler().run(({ cold, flush}) => { + TestBed.overrideProvider(Store, { useValue: cold('t--t--f--t--f--', BLOCKING) }); + const service = TestBed.inject(InitService); + + // @ts-ignore + service.initKlaro(); + flush(); + expect(klaroServiceSpy.initialize).toHaveBeenCalledTimes(1); + }); + }); + }); + }); +}); + diff --git a/src/modules/app/browser-init.service.ts b/src/modules/app/browser-init.service.ts new file mode 100644 index 0000000000..80619800fb --- /dev/null +++ b/src/modules/app/browser-init.service.ts @@ -0,0 +1,136 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { InitService } from '../../app/init.service'; +import { select, Store } from '@ngrx/store'; +import { AppState } from '../../app/app.reducer'; +import { TransferState } from '@angular/platform-browser'; +import { APP_CONFIG, APP_CONFIG_STATE, AppConfig } from '../../config/app-config.interface'; +import { DefaultAppConfig } from '../../config/default-app-config'; +import { extendEnvironmentWithAppConfig } from '../../config/config.util'; +import { environment } from '../../environments/environment'; +import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; +import { Inject, Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { LocaleService } from '../../app/core/locale/locale.service'; +import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { GoogleAnalyticsService } from '../../app/statistics/google-analytics.service'; +import { MetadataService } from '../../app/core/metadata/metadata.service'; +import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; +import { KlaroService } from '../../app/shared/cookies/klaro.service'; +import { AuthService } from '../../app/core/auth/auth.service'; +import { ThemeService } from '../../app/shared/theme-support/theme.service'; +import { StoreAction, StoreActionTypes } from '../../app/store.actions'; +import { coreSelector } from '../../app/core/core.selectors'; +import { distinctUntilChanged, filter, find, map, take } from 'rxjs/operators'; +import { isNotEmpty } from '../../app/shared/empty.util'; +import { isAuthenticationBlocking } from '../../app/core/auth/selectors'; + +/** + * Performs client-side initialization. + */ +@Injectable() +export class BrowserInitService extends InitService { + constructor( + protected store: Store, + protected correlationIdService: CorrelationIdService, + protected transferState: TransferState, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + protected translate: TranslateService, + protected localeService: LocaleService, + protected angulartics2DSpace: Angulartics2DSpace, + protected googleAnalyticsService: GoogleAnalyticsService, + protected metadata: MetadataService, + protected breadcrumbsService: BreadcrumbsService, + protected klaroService: KlaroService, + protected authService: AuthService, + protected themeService: ThemeService, + ) { + super( + store, + correlationIdService, + appConfig, + translate, + localeService, + angulartics2DSpace, + metadata, + breadcrumbsService, + themeService, + ); + } + + protected static resolveAppConfig( + transferState: TransferState, + ) { + if (transferState.hasKey(APP_CONFIG_STATE)) { + const appConfig = transferState.get(APP_CONFIG_STATE, new DefaultAppConfig()); + // extend environment with app config for browser + extendEnvironmentWithAppConfig(environment, appConfig); + } + } + + protected init(): () => Promise { + return async () => { + await this.loadAppState(); + this.checkAuthenticationToken(); + this.initCorrelationId(); + + this.checkEnvironment(); + + this.initI18n(); + this.initAngulartics(); + this.initGoogleAnalytics(); + this.initRouteListeners(); + this.themeService.listenForThemeChanges(true); + this.trackAuthTokenExpiration(); + + this.initKlaro(); + + return true; + }; + } + + // Browser-only initialization steps + + /** + * Retrieve server-side application state from the {@link NGRX_STATE} key and rehydrate the store. + * Resolves once the store is no longer empty. + * @private + */ + private async loadAppState(): Promise { + const state = this.transferState.get(InitService.NGRX_STATE, null); + this.transferState.remove(InitService.NGRX_STATE); + this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state)); + return this.store.select(coreSelector).pipe( + find((core: any) => isNotEmpty(core)), + map(() => true) + ).toPromise(); + } + + private trackAuthTokenExpiration(): void { + this.authService.trackTokenExpiration(); + } + + /** + * Initialize Klaro + * @protected + */ + protected initKlaro() { + this.store.pipe( + select(isAuthenticationBlocking), + distinctUntilChanged(), + filter((isBlocking: boolean) => isBlocking === false), + take(1) + ).subscribe(() => { + this.klaroService.initialize(); + }); + } + + protected initGoogleAnalytics() { + this.googleAnalyticsService.addTrackingIdToPage(); + } +} diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index 236b7bc5a0..35fa050d6f 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -1,8 +1,8 @@ import { HTTP_INTERCEPTORS } from '@angular/common/http'; -import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { NgModule } from '@angular/core'; import { BrowserModule, TransferState } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { ServerModule } from '@angular/platform-server'; +import { ServerModule, ServerTransferStateModule } from '@angular/platform-server'; import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; @@ -12,8 +12,6 @@ import { Angulartics2GoogleAnalytics } from 'angulartics2'; import { AppComponent } from '../../app/app.component'; import { AppModule } from '../../app/app.module'; -import { DSpaceServerTransferStateModule } from '../transfer-state/dspace-server-transfer-state.module'; -import { DSpaceTransferState } from '../transfer-state/dspace-transfer-state.service'; import { TranslateServerLoader } from '../../ngx-translate-loaders/translate-server.loader'; import { CookieService } from '../../app/core/services/cookie.service'; import { ServerCookieService } from '../../app/core/services/server-cookie.service'; @@ -31,10 +29,7 @@ import { ServerHardRedirectService } from '../../app/core/services/server-hard-r import { Angulartics2Mock } from '../../app/shared/mocks/angulartics2.service.mock'; import { AuthRequestService } from '../../app/core/auth/auth-request.service'; import { ServerAuthRequestService } from '../../app/core/auth/server-auth-request.service'; -import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; -import { AppConfig, APP_CONFIG_STATE } from '../../config/app-config.interface'; - -import { environment } from '../../environments/environment'; +import { ServerInitService } from './server-init.service'; export function createTranslateLoader(transferState: TransferState) { return new TranslateServerLoader(transferState, 'dist/server/assets/i18n/', '.json5'); @@ -47,7 +42,7 @@ export function createTranslateLoader(transferState: TransferState) { appId: 'dspace-angular' }), NoopAnimationsModule, - DSpaceServerTransferStateModule, + ServerTransferStateModule, TranslateModule.forRoot({ loader: { provide: TranslateLoader, @@ -59,22 +54,7 @@ export function createTranslateLoader(transferState: TransferState) { ServerModule, ], providers: [ - // Initialize app config and extend environment - { - provide: APP_INITIALIZER, - useFactory: ( - transferState: TransferState, - dspaceTransferState: DSpaceTransferState, - correlationIdService: CorrelationIdService, - ) => { - transferState.set(APP_CONFIG_STATE, environment as AppConfig); - dspaceTransferState.transfer(); - correlationIdService.initCorrelationId(); - return () => true; - }, - deps: [TransferState, DSpaceTransferState, CorrelationIdService], - multi: true - }, + ...ServerInitService.providers(), { provide: Angulartics2, useClass: Angulartics2Mock diff --git a/src/modules/app/server-init.service.ts b/src/modules/app/server-init.service.ts new file mode 100644 index 0000000000..803dc7a75a --- /dev/null +++ b/src/modules/app/server-init.service.ts @@ -0,0 +1,93 @@ +/** + * The contents of this file are subject to the license and copyright + * detailed in the LICENSE and NOTICE files at the root of the source + * tree and available online at + * + * http://www.dspace.org/license/ + */ +import { InitService } from '../../app/init.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '../../app/app.reducer'; +import { TransferState } from '@angular/platform-browser'; +import { CorrelationIdService } from '../../app/correlation-id/correlation-id.service'; +import { APP_CONFIG, APP_CONFIG_STATE, AppConfig } from '../../config/app-config.interface'; +import { environment } from '../../environments/environment'; +import { Inject, Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { LocaleService } from '../../app/core/locale/locale.service'; +import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; +import { MetadataService } from '../../app/core/metadata/metadata.service'; +import { BreadcrumbsService } from '../../app/breadcrumbs/breadcrumbs.service'; +import { CSSVariableService } from '../../app/shared/sass-helper/sass-helper.service'; +import { ThemeService } from '../../app/shared/theme-support/theme.service'; +import { take } from 'rxjs/operators'; + +/** + * Performs server-side initialization. + */ +@Injectable() +export class ServerInitService extends InitService { + constructor( + protected store: Store, + protected correlationIdService: CorrelationIdService, + protected transferState: TransferState, + @Inject(APP_CONFIG) protected appConfig: AppConfig, + protected translate: TranslateService, + protected localeService: LocaleService, + protected angulartics2DSpace: Angulartics2DSpace, + protected metadata: MetadataService, + protected breadcrumbsService: BreadcrumbsService, + protected cssService: CSSVariableService, + protected themeService: ThemeService, + ) { + super( + store, + correlationIdService, + appConfig, + translate, + localeService, + angulartics2DSpace, + metadata, + breadcrumbsService, + themeService, + ); + } + + protected init(): () => Promise { + return async () => { + this.checkAuthenticationToken(); + this.saveAppConfigForCSR(); + this.saveAppState(); + this.initCorrelationId(); + + this.checkEnvironment(); + this.initI18n(); + this.initAngulartics(); + this.initRouteListeners(); + this.themeService.listenForThemeChanges(false); + + return true; + }; + } + + // Server-only initialization steps + + /** + * Set the {@link NGRX_STATE} key when state is serialized to be transfered + * @private + */ + private saveAppState() { + this.transferState.onSerialize(InitService.NGRX_STATE, () => { + let state; + this.store.pipe(take(1)).subscribe((saveState: any) => { + state = saveState; + }); + + return state; + }); + } + + private saveAppConfigForCSR(): void { + this.transferState.set(APP_CONFIG_STATE, environment as AppConfig); + } +} diff --git a/src/modules/transfer-state/dspace-browser-transfer-state.module.ts b/src/modules/transfer-state/dspace-browser-transfer-state.module.ts deleted file mode 100644 index e251d0b3b2..0000000000 --- a/src/modules/transfer-state/dspace-browser-transfer-state.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core'; -import { BrowserTransferStateModule } from '@angular/platform-browser'; -import { DSpaceBrowserTransferState } from './dspace-browser-transfer-state.service'; -import { DSpaceTransferState } from './dspace-transfer-state.service'; - -@NgModule({ - imports: [ - BrowserTransferStateModule - ], - providers: [ - { provide: DSpaceTransferState, useClass: DSpaceBrowserTransferState } - ] -}) -export class DSpaceBrowserTransferStateModule { - -} diff --git a/src/modules/transfer-state/dspace-browser-transfer-state.service.ts b/src/modules/transfer-state/dspace-browser-transfer-state.service.ts deleted file mode 100644 index 512d6aeb71..0000000000 --- a/src/modules/transfer-state/dspace-browser-transfer-state.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@angular/core'; -import { coreSelector } from 'src/app/core/core.selectors'; -import { StoreAction, StoreActionTypes } from '../../app/store.actions'; -import { DSpaceTransferState } from './dspace-transfer-state.service'; -import { find, map } from 'rxjs/operators'; -import { isNotEmpty } from '../../app/shared/empty.util'; - -@Injectable() -export class DSpaceBrowserTransferState extends DSpaceTransferState { - transfer(): Promise { - const state = this.transferState.get(DSpaceTransferState.NGRX_STATE, null); - this.transferState.remove(DSpaceTransferState.NGRX_STATE); - this.store.dispatch(new StoreAction(StoreActionTypes.REHYDRATE, state)); - return this.store.select(coreSelector).pipe( - find((core: any) => isNotEmpty(core)), - map(() => true) - ).toPromise(); - } -} diff --git a/src/modules/transfer-state/dspace-server-transfer-state.module.ts b/src/modules/transfer-state/dspace-server-transfer-state.module.ts deleted file mode 100644 index f8f2631cd0..0000000000 --- a/src/modules/transfer-state/dspace-server-transfer-state.module.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NgModule } from '@angular/core'; -import { ServerTransferStateModule } from '@angular/platform-server'; -import { DSpaceServerTransferState } from './dspace-server-transfer-state.service'; -import { DSpaceTransferState } from './dspace-transfer-state.service'; - -@NgModule({ - imports: [ - ServerTransferStateModule - ], - providers: [ - { provide: DSpaceTransferState, useClass: DSpaceServerTransferState } - ] -}) -export class DSpaceServerTransferStateModule { - -} diff --git a/src/modules/transfer-state/dspace-server-transfer-state.service.ts b/src/modules/transfer-state/dspace-server-transfer-state.service.ts deleted file mode 100644 index 96b1e4be38..0000000000 --- a/src/modules/transfer-state/dspace-server-transfer-state.service.ts +++ /dev/null @@ -1,20 +0,0 @@ - -import {take} from 'rxjs/operators'; -import { Injectable } from '@angular/core'; -import { DSpaceTransferState } from './dspace-transfer-state.service'; - -@Injectable() -export class DSpaceServerTransferState extends DSpaceTransferState { - transfer(): Promise { - this.transferState.onSerialize(DSpaceTransferState.NGRX_STATE, () => { - let state; - this.store.pipe(take(1)).subscribe((saveState: any) => { - state = saveState; - }); - - return state; - }); - - return new Promise(() => true); - } -} diff --git a/src/modules/transfer-state/dspace-transfer-state.service.ts b/src/modules/transfer-state/dspace-transfer-state.service.ts deleted file mode 100644 index 32761866fb..0000000000 --- a/src/modules/transfer-state/dspace-transfer-state.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@angular/core'; -import { makeStateKey, TransferState } from '@angular/platform-browser'; -import { Store } from '@ngrx/store'; -import { AppState } from '../../app/app.reducer'; - -@Injectable() -export abstract class DSpaceTransferState { - - protected static NGRX_STATE = makeStateKey('NGRX_STATE'); - - constructor( - protected transferState: TransferState, - protected store: Store - ) { - } - - abstract transfer(): Promise; -}