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 e7033f51ba..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,49 +7,31 @@ import { HostListener, Inject, OnInit, - Optional, PLATFORM_ID, } from '@angular/core'; import { - ActivatedRouteSnapshot, - ActivationEnd, NavigationCancel, NavigationEnd, NavigationStart, - ResolveEnd, 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 { APP_CONFIG, AppConfig } from '../config/app-config.interface'; +import { distinctNext } from './core/shared/distinct-next'; +import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface'; @Component({ selector: 'ds-app', @@ -58,11 +40,6 @@ import { APP_CONFIG, AppConfig } from '../config/app-config.interface'; 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/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/menu.resolver.ts b/src/app/menu.resolver.ts index 4c97d3d1b3..f12079f737 100644 --- a/src/app/menu.resolver.ts +++ b/src/app/menu.resolver.ts @@ -16,24 +16,24 @@ import { filter, find, map, take } from 'rxjs/operators'; import { hasValue } from './shared/empty.util'; import { FeatureID } from './core/data/feature-authorization/feature-id'; import { - CreateCommunityParentSelectorComponent -} from './shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; + ThemedCreateCommunityParentSelectorComponent +} from './shared/dso-selector/modal-wrappers/create-community-parent-selector/themed-create-community-parent-selector.component'; import { OnClickMenuItemModel } from './shared/menu/menu-item/models/onclick.model'; import { - CreateCollectionParentSelectorComponent -} from './shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; + ThemedCreateCollectionParentSelectorComponent +} from './shared/dso-selector/modal-wrappers/create-collection-parent-selector/themed-create-collection-parent-selector.component'; import { - CreateItemParentSelectorComponent -} from './shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; + ThemedCreateItemParentSelectorComponent +} from './shared/dso-selector/modal-wrappers/create-item-parent-selector/themed-create-item-parent-selector.component'; import { - EditCommunitySelectorComponent -} from './shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; + ThemedEditCommunitySelectorComponent +} from './shared/dso-selector/modal-wrappers/edit-community-selector/themed-edit-community-selector.component'; import { - EditCollectionSelectorComponent -} from './shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; + ThemedEditCollectionSelectorComponent +} from './shared/dso-selector/modal-wrappers/edit-collection-selector/themed-edit-collection-selector.component'; import { - EditItemSelectorComponent -} from './shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; + ThemedEditItemSelectorComponent +} from './shared/dso-selector/modal-wrappers/edit-item-selector/themed-edit-item-selector.component'; import { ExportMetadataSelectorComponent } from './shared/dso-selector/modal-wrappers/export-metadata-selector/export-metadata-selector.component'; @@ -188,7 +188,7 @@ export class MenuResolver implements Resolve { type: MenuItemType.ONCLICK, text: 'menu.section.new_community', function: () => { - this.modalService.open(CreateCommunityParentSelectorComponent); + this.modalService.open(ThemedCreateCommunityParentSelectorComponent); } } as OnClickMenuItemModel, }, @@ -201,7 +201,7 @@ export class MenuResolver implements Resolve { type: MenuItemType.ONCLICK, text: 'menu.section.new_collection', function: () => { - this.modalService.open(CreateCollectionParentSelectorComponent); + this.modalService.open(ThemedCreateCollectionParentSelectorComponent); } } as OnClickMenuItemModel, }, @@ -214,7 +214,7 @@ export class MenuResolver implements Resolve { type: MenuItemType.ONCLICK, text: 'menu.section.new_item', function: () => { - this.modalService.open(CreateItemParentSelectorComponent); + this.modalService.open(ThemedCreateItemParentSelectorComponent); } } as OnClickMenuItemModel, }, @@ -263,7 +263,7 @@ export class MenuResolver implements Resolve { type: MenuItemType.ONCLICK, text: 'menu.section.edit_community', function: () => { - this.modalService.open(EditCommunitySelectorComponent); + this.modalService.open(ThemedEditCommunitySelectorComponent); } } as OnClickMenuItemModel, }, @@ -276,7 +276,7 @@ export class MenuResolver implements Resolve { type: MenuItemType.ONCLICK, text: 'menu.section.edit_collection', function: () => { - this.modalService.open(EditCollectionSelectorComponent); + this.modalService.open(ThemedEditCollectionSelectorComponent); } } as OnClickMenuItemModel, }, @@ -289,7 +289,7 @@ export class MenuResolver implements Resolve { type: MenuItemType.ONCLICK, text: 'menu.section.edit_item', function: () => { - this.modalService.open(EditItemSelectorComponent); + this.modalService.open(ThemedEditItemSelectorComponent); } } as OnClickMenuItemModel, }, diff --git a/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/themed-create-collection-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/themed-create-collection-parent-selector.component.ts new file mode 100644 index 0000000000..d90cd0ac0d --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/themed-create-collection-parent-selector.component.ts @@ -0,0 +1,28 @@ +import {Component} from '@angular/core'; +import {CreateCollectionParentSelectorComponent} from './create-collection-parent-selector.component'; +import {ThemedComponent} from 'src/app/shared/theme-support/themed.component'; + +/** + * Themed wrapper for CreateCollectionParentSelectorComponent + */ +@Component({ + selector: 'ds-themed-create-collection-parent-selector', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html' +}) +export class ThemedCreateCollectionParentSelectorComponent + extends ThemedComponent { + + protected getComponentName(): string { + return 'CreateCollectionParentSelectorComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./create-collection-parent-selector.component'); + } + +} diff --git a/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/themed-create-community-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/themed-create-community-parent-selector.component.ts new file mode 100644 index 0000000000..24bff97254 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/themed-create-community-parent-selector.component.ts @@ -0,0 +1,27 @@ +import {Component} from '@angular/core'; +import {CreateCommunityParentSelectorComponent} from './create-community-parent-selector.component'; +import {ThemedComponent} from 'src/app/shared/theme-support/themed.component'; + +/** + * Themed wrapper for CreateCommunityParentSelectorComponent + */ +@Component({ + selector: 'ds-themed-create-community-parent-selector', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html' +}) +export class ThemedCreateCommunityParentSelectorComponent + extends ThemedComponent { + protected getComponentName(): string { + return 'CreateCommunityParentSelectorComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./create-community-parent-selector.component'); + } + +} diff --git a/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/themed-create-item-parent-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/themed-create-item-parent-selector.component.ts new file mode 100644 index 0000000000..49209ea63b --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/themed-create-item-parent-selector.component.ts @@ -0,0 +1,31 @@ +import {Component, Input} from '@angular/core'; +import {CreateItemParentSelectorComponent} from './create-item-parent-selector.component'; +import {ThemedComponent} from 'src/app/shared/theme-support/themed.component'; + +/** + * Themed wrapper for CreateItemParentSelectorComponent + */ +@Component({ + selector: 'ds-themed-create-item-parent-selector', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html' +}) +export class ThemedCreateItemParentSelectorComponent + extends ThemedComponent { + @Input() entityType: string; + + protected inAndOutputNames: (keyof CreateItemParentSelectorComponent & keyof this)[] = ['entityType']; + + protected getComponentName(): string { + return 'CreateItemParentSelectorComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./create-item-parent-selector.component'); + } + +} diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/themed-edit-collection-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/themed-edit-collection-selector.component.ts new file mode 100644 index 0000000000..999f466e75 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/edit-collection-selector/themed-edit-collection-selector.component.ts @@ -0,0 +1,27 @@ +import {Component} from '@angular/core'; +import {EditCollectionSelectorComponent} from './edit-collection-selector.component'; +import {ThemedComponent} from 'src/app/shared/theme-support/themed.component'; + +/** + * Themed wrapper for EditCollectionSelectorComponent + */ +@Component({ + selector: 'ds-themed-edit-collection-selector', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html' +}) +export class ThemedEditCollectionSelectorComponent + extends ThemedComponent { + protected getComponentName(): string { + return 'EditCollectionSelectorComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./edit-collection-selector.component'); + } + +} diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/themed-edit-community-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/themed-edit-community-selector.component.ts new file mode 100644 index 0000000000..e067803444 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/edit-community-selector/themed-edit-community-selector.component.ts @@ -0,0 +1,27 @@ +import {Component} from '@angular/core'; +import {EditCommunitySelectorComponent} from './edit-community-selector.component'; +import {ThemedComponent} from 'src/app/shared/theme-support/themed.component'; + +/** + * Themed wrapper for EditCommunitySelectorComponent + */ +@Component({ + selector: 'ds-themed-edit-community-selector', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html' +}) +export class ThemedEditCommunitySelectorComponent + extends ThemedComponent { + protected getComponentName(): string { + return 'EditCommunitySelectorComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./edit-community-selector.component'); + } + +} diff --git a/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/themed-edit-item-selector.component.ts b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/themed-edit-item-selector.component.ts new file mode 100644 index 0000000000..6d3b5691c1 --- /dev/null +++ b/src/app/shared/dso-selector/modal-wrappers/edit-item-selector/themed-edit-item-selector.component.ts @@ -0,0 +1,27 @@ +import {Component} from '@angular/core'; +import {EditItemSelectorComponent} from './edit-item-selector.component'; +import {ThemedComponent} from 'src/app/shared/theme-support/themed.component'; + +/** + * Themed wrapper for EditItemSelectorComponent + */ +@Component({ + selector: 'ds-themed-edit-item-selector', + styleUrls: [], + templateUrl: '../../../theme-support/themed.component.html' +}) +export class ThemedEditItemSelectorComponent + extends ThemedComponent { + protected getComponentName(): string { + return 'EditItemSelectorComponent'; + } + + protected importThemedComponent(themeName: string): Promise { + return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component`); + } + + protected importUnthemedComponent(): Promise { + return import('./edit-item-selector.component'); + } + +} 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/shared.module.ts b/src/app/shared/shared.module.ts index 9ca2f22fa4..a9a87845fb 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -124,12 +124,21 @@ import { DSOSelectorComponent } from './dso-selector/dso-selector/dso-selector.c import { CreateCommunityParentSelectorComponent } from './dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; +import { + ThemedCreateCommunityParentSelectorComponent +} from './dso-selector/modal-wrappers/create-community-parent-selector/themed-create-community-parent-selector.component'; import { CreateItemParentSelectorComponent } from './dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { + ThemedCreateItemParentSelectorComponent +} from './dso-selector/modal-wrappers/create-item-parent-selector/themed-create-item-parent-selector.component'; import { CreateCollectionParentSelectorComponent } from './dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; +import { + ThemedCreateCollectionParentSelectorComponent +} from './dso-selector/modal-wrappers/create-collection-parent-selector/themed-create-collection-parent-selector.component'; import { CommunitySearchResultListElementComponent } from './object-list/search-result-list-element/community-search-result/community-search-result-list-element.component'; @@ -139,12 +148,21 @@ import { import { EditItemSelectorComponent } from './dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; +import { + ThemedEditItemSelectorComponent +} from './dso-selector/modal-wrappers/edit-item-selector/themed-edit-item-selector.component'; import { EditCommunitySelectorComponent } from './dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; +import { + ThemedEditCommunitySelectorComponent +} from './dso-selector/modal-wrappers/edit-community-selector/themed-edit-community-selector.component'; import { EditCollectionSelectorComponent } from './dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; +import { + ThemedEditCollectionSelectorComponent +} from './dso-selector/modal-wrappers/edit-collection-selector/themed-edit-collection-selector.component'; import { ItemListPreviewComponent } from './object-list/my-dspace-result-list-element/item-list-preview/item-list-preview.component'; @@ -397,11 +415,17 @@ const COMPONENTS = [ DsoInputSuggestionsComponent, DSOSelectorComponent, CreateCommunityParentSelectorComponent, + ThemedCreateCommunityParentSelectorComponent, CreateCollectionParentSelectorComponent, + ThemedCreateCollectionParentSelectorComponent, CreateItemParentSelectorComponent, + ThemedCreateItemParentSelectorComponent, EditCommunitySelectorComponent, + ThemedEditCommunitySelectorComponent, EditCollectionSelectorComponent, + ThemedEditCollectionSelectorComponent, EditItemSelectorComponent, + ThemedEditItemSelectorComponent, CommunitySearchResultListElementComponent, CollectionSearchResultListElementComponent, BrowseByComponent, @@ -493,11 +517,17 @@ const ENTRY_COMPONENTS = [ StartsWithDateComponent, StartsWithTextComponent, CreateCommunityParentSelectorComponent, + ThemedCreateCommunityParentSelectorComponent, CreateCollectionParentSelectorComponent, + ThemedCreateCollectionParentSelectorComponent, CreateItemParentSelectorComponent, + ThemedCreateItemParentSelectorComponent, EditCommunitySelectorComponent, + ThemedEditCommunitySelectorComponent, EditCollectionSelectorComponent, + ThemedEditCollectionSelectorComponent, EditItemSelectorComponent, + ThemedEditItemSelectorComponent, PlainTextMetadataListElementComponent, ItemMetadataListElementComponent, MetadataRepresentationListElementComponent, 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; -} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts new file mode 100644 index 0000000000..22d40ff539 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { + CreateCollectionParentSelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; + +@Component({ + selector: 'ds-create-collection-parent-selector', + // styleUrls: ['./create-collection-parent-selector.component.scss'], + // templateUrl: './create-collection-parent-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', +}) +export class CreateCollectionParentSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html new file mode 100644 index 0000000000..84fdd34c01 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html @@ -0,0 +1,19 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss new file mode 100644 index 0000000000..0daf4cfa5f --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss @@ -0,0 +1,3 @@ +#create-community-or-separator { + top: 0; +} \ No newline at end of file diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts new file mode 100644 index 0000000000..8b28ee1bb8 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.ts @@ -0,0 +1,14 @@ +import { Component } from '@angular/core'; +import { + CreateCommunityParentSelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; + +@Component({ + selector: 'ds-create-community-parent-selector', + // styleUrls: ['./create-community-parent-selector.component.scss'], + styleUrls: ['../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.scss'], + // templateUrl: './create-community-parent-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component.html', +}) +export class CreateCommunityParentSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html new file mode 100644 index 0000000000..664aef95c0 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html @@ -0,0 +1,15 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts new file mode 100644 index 0000000000..f8e3401454 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.ts @@ -0,0 +1,13 @@ +import {Component} from '@angular/core'; +import { + CreateItemParentSelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; + +@Component({ + selector: 'ds-create-item-parent-selector', + // styleUrls: ['./create-item-parent-selector.component.scss'], + // templateUrl: './create-item-parent-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component.html', +}) +export class CreateItemParentSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts new file mode 100644 index 0000000000..8f4a8dd5cd --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { + EditCollectionSelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; + +@Component({ + selector: 'ds-edit-collection-selector', + // styleUrls: ['./edit-collection-selector.component.scss'], + // templateUrl: './edit-collection-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', +}) +export class EditCollectionSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts new file mode 100644 index 0000000000..79d52fc350 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { + EditCommunitySelectorComponent as BaseComponent +} from '../../../../../../../app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; + +@Component({ + selector: 'ds-edit-item-selector', + // styleUrls: ['./edit-community-selector.component.scss'], + // templateUrl: './edit-community-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', +}) +export class EditCommunitySelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html new file mode 100644 index 0000000000..85d8797e66 --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.html @@ -0,0 +1,11 @@ +
+ + +
diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.scss b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts new file mode 100644 index 0000000000..398dbc933c --- /dev/null +++ b/src/themes/custom/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { + EditItemSelectorComponent as BaseComponent +} from 'src/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; + +@Component({ + selector: 'ds-edit-item-selector', + // styleUrls: ['./edit-item-selector.component.scss'], + // templateUrl: './edit-item-selector.component.html', + templateUrl: '../../../../../../../app/shared/dso-selector/modal-wrappers/dso-selector-modal-wrapper.component.html', +}) +export class EditItemSelectorComponent extends BaseComponent { +} diff --git a/src/themes/custom/eager-theme.module.ts b/src/themes/custom/eager-theme.module.ts index 5256d2fd7c..4c75f63cc9 100644 --- a/src/themes/custom/eager-theme.module.ts +++ b/src/themes/custom/eager-theme.module.ts @@ -21,6 +21,24 @@ import { } from './app/entity-groups/journal-entities/item-pages/journal-volume/journal-volume.component'; import { UntypedItemComponent } from './app/item-page/simple/item-types/untyped-item/untyped-item.component'; import { ItemSharedModule } from '../../app/item-page/item-shared.module'; +import { + CreateCollectionParentSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component'; +import { + CreateCommunityParentSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component'; +import { + CreateItemParentSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component'; +import { + EditCollectionSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component'; +import { + EditCommunitySelectorComponent +} from './app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component'; +import { + EditItemSelectorComponent +} from './app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component'; /** * Add components that use a custom decorator to ENTRY_COMPONENTS as well as DECLARATIONS. @@ -41,6 +59,12 @@ const DECLARATIONS = [ HeaderNavbarWrapperComponent, NavbarComponent, FooterComponent, + CreateCollectionParentSelectorComponent, + CreateCommunityParentSelectorComponent, + CreateItemParentSelectorComponent, + EditCollectionSelectorComponent, + EditCommunitySelectorComponent, + EditItemSelectorComponent, ]; @NgModule({