Merge branch 'main' into CST-6153

This commit is contained in:
Giuseppe Digilio
2022-09-08 18:02:03 +02:00
62 changed files with 7660 additions and 5712 deletions

16
.gitattributes vendored
View File

@@ -1,2 +1,16 @@
# Auto detect text files and perform LF normalization
# By default, auto detect text files and perform LF normalization
# This ensures code is always checked in with LF line endings
* text=auto
# JS and TS files must always use LF for Angular tools to work
# Some Angular tools expect LF line endings, even on Windows.
# This ensures Windows always checks out these files with LF line endings
# We've copied many of these rules from https://github.com/angular/angular-cli/
*.js eol=lf
*.ts eol=lf
*.json eol=lf
*.json5 eol=lf
*.css eol=lf
*.scss eol=lf
*.html eol=lf
*.svg eol=lf

0
scripts/sync-i18n-files.ts Executable file → Normal file
View File

View File

@@ -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);
});
});
});

View File

@@ -1,4 +1,4 @@
import { distinctUntilChanged, filter, switchMap, take, withLatestFrom } from 'rxjs/operators';
import { distinctUntilChanged, take, withLatestFrom } from 'rxjs/operators';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import {
AfterViewInit,
@@ -7,48 +7,30 @@ import {
HostListener,
Inject,
OnInit,
Optional,
PLATFORM_ID,
} from '@angular/core';
import {
ActivatedRouteSnapshot,
ActivationEnd,
NavigationCancel,
NavigationEnd,
NavigationStart, ResolveEnd,
NavigationStart,
Router,
} from '@angular/router';
import { isEqual } from 'lodash';
import { BehaviorSubject, Observable, of } from 'rxjs';
import { BehaviorSubject, Observable } from 'rxjs';
import { select, Store } from '@ngrx/store';
import { NgbModal, NgbModalConfig } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core';
import { Angulartics2GoogleAnalytics } from 'angulartics2';
import { MetadataService } from './core/metadata/metadata.service';
import { HostWindowResizeAction } from './shared/host-window.actions';
import { HostWindowState } from './shared/search/host-window.reducer';
import { NativeWindowRef, NativeWindowService } from './core/services/window.service';
import { isAuthenticationBlocking } from './core/auth/selectors';
import { AuthService } from './core/auth/auth.service';
import { CSSVariableService } from './shared/sass-helper/sass-helper.service';
import { MenuService } from './shared/menu/menu.service';
import { HostWindowService } from './shared/host-window.service';
import { HeadTagConfig, ThemeConfig } from '../config/theme.model';
import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider';
import { environment } from '../environments/environment';
import { models } from './core/core.module';
import { LocaleService } from './core/locale/locale.service';
import { hasNoValue, hasValue, isNotEmpty } from './shared/empty.util';
import { KlaroService } from './shared/cookies/klaro.service';
import { GoogleAnalyticsService } from './statistics/google-analytics.service';
import { ThemeService } from './shared/theme-support/theme.service';
import { BASE_THEME_NAME } from './shared/theme-support/theme.constants';
import { BreadcrumbsService } from './breadcrumbs/breadcrumbs.service';
import { IdleModalComponent } from './shared/idle-modal/idle-modal.component';
import { getDefaultThemeConfig } from '../config/config.util';
import { AppConfig, APP_CONFIG } from 'src/config/app-config.interface';
import { distinctNext } from './core/shared/distinct-next';
import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.interface';
@Component({
@@ -58,11 +40,6 @@ import { ModalBeforeDismiss } from './shared/interfaces/modal-before-dismiss.int
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit, AfterViewInit {
sidebarVisible: Observable<boolean>;
slideSidebarOver: Observable<boolean>;
collapsedSidebarWidth: Observable<string>;
totalSidebarWidth: Observable<string>;
theme: Observable<ThemeConfig> = 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<boolean> = new BehaviorSubject(false);
isThemeCSSLoading$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
isThemeLoading$: Observable<boolean>;
/**
* 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<HostWindowState>,
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 <head>
*
* @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<T>(bs: BehaviorSubject<T>, nextValue: T): void {
if (bs.getValue() !== nextValue) {
bs.next(nextValue);
}
}
}

25
src/app/app.module.ts Executable file → Normal file
View File

@@ -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<AppState>,) => {
return () => store.dispatch(new CheckAuthenticationTokenAction());
},
deps: [Store],
multi: true
},
// register AuthInterceptor as HttpInterceptor
{
provide: HTTP_INTERCEPTORS,

View File

@@ -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'));

View File

@@ -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<T>(bs: BehaviorSubject<T>, nextValue: T): void {
if (bs.getValue() !== nextValue) {
bs.next(nextValue);
}
}

View File

@@ -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<any>;
@Injectable()
export class ConcreteInitServiceMock extends InitService {
protected static resolveAppConfig() {
spy.resolveAppConfig();
}
protected init(): () => Promise<boolean> {
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);
}));
});
});
});

189
src/app/init.service.ts Normal file
View File

@@ -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<AppState>,
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}
* <br>
* 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<InitService>,
},
{
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<boolean>;
// 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();
}
}

View File

@@ -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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
type: MenuItemType.ONCLICK,
text: 'menu.section.edit_item',
function: () => {
this.modalService.open(EditItemSelectorComponent);
this.modalService.open(ThemedEditItemSelectorComponent);
}
} as OnClickMenuItemModel,
},

View File

@@ -1,53 +1,99 @@
<div class="container" *ngVar="(processRD$ | async)?.payload as process">
<div class="d-flex">
<h2 class="flex-grow-1">{{'process.detail.title' | translate:{id: process?.processId, name: process?.scriptName} }}</h2>
<div>
<button class="btn btn-lg btn-success " routerLink="/processes/new" [queryParams]="{id: process?.processId}"><i class="fas fa-plus pr-2"></i>{{'process.detail.create' | translate}}</button>
</div>
<h2 class="flex-grow-1">{{'process.detail.title' | translate:{
id: process?.processId,
name: process?.scriptName
} }}</h2>
</div>
<ds-process-detail-field id="process-name" [title]="'process.detail.script'">
<div>{{ process?.scriptName }}</div>
</ds-process-detail-field>
<ds-process-detail-field *ngIf="process?.parameters && process?.parameters?.length > 0" id="process-arguments" [title]="'process.detail.arguments'">
<ds-process-detail-field *ngIf="process?.parameters && process?.parameters?.length > 0" id="process-arguments"
[title]="'process.detail.arguments'">
<div *ngFor="let argument of process?.parameters">{{ argument?.name }} {{ argument?.value }}</div>
</ds-process-detail-field>
<div *ngVar="(filesRD$ | async)?.payload?.page as files">
<ds-process-detail-field *ngIf="files && files?.length > 0" id="process-files" [title]="'process.detail.output-files'">
<ds-file-download-link *ngFor="let file of files; let last=last;" [bitstream]="file">
<span>{{getFileName(file)}}</span>
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
</ds-file-download-link>
<ds-process-detail-field *ngIf="files && files?.length > 0" id="process-files"
[title]="'process.detail.output-files'">
<ds-file-download-link *ngFor="let file of files; let last=last;" [bitstream]="file">
<span>{{getFileName(file)}}</span>
<span>({{(file?.sizeBytes) | dsFileSize }})</span>
</ds-file-download-link>
</ds-process-detail-field>
</div>
<ds-process-detail-field *ngIf="process && process.startTime" id="process-start-time" [title]="'process.detail.start-time' | translate">
<ds-process-detail-field *ngIf="process && process.startTime" id="process-start-time"
[title]="'process.detail.start-time' | translate">
<div>{{ process.startTime | date:dateFormat:'UTC' }}</div>
</ds-process-detail-field>
<ds-process-detail-field *ngIf="process && process.endTime" id="process-end-time" [title]="'process.detail.end-time' | translate">
<ds-process-detail-field *ngIf="process && process.endTime" id="process-end-time"
[title]="'process.detail.end-time' | translate">
<div>{{ process.endTime | date:dateFormat:'UTC' }}</div>
</ds-process-detail-field>
<ds-process-detail-field *ngIf="process && process.processStatus" id="process-status" [title]="'process.detail.status' | translate">
<ds-process-detail-field *ngIf="process && process.processStatus" id="process-status"
[title]="'process.detail.status' | translate">
<div>{{ process.processStatus }}</div>
</ds-process-detail-field>
<ds-process-detail-field *ngIf="isProcessFinished(process)" id="process-output" [title]="'process.detail.output'">
<button *ngIf="!showOutputLogs && process?._links?.output?.href != undefined" id="showOutputButton" class="btn btn-primary" (click)="showProcessOutputLogs()">
{{ 'process.detail.logs.button' | translate }}
</button>
<ds-themed-loading *ngIf="retrievingOutputLogs$ | async" class="ds-themed-loading" message="{{ 'process.detail.logs.loading' | translate }}"></ds-themed-loading>
<pre class="font-weight-bold text-secondary bg-light p-3"
*ngIf="showOutputLogs && (outputLogs$ | async)?.length > 0">{{ (outputLogs$ | async) }}</pre>
<p id="no-output-logs-message" *ngIf="(!(retrievingOutputLogs$ | async) && showOutputLogs)
<button *ngIf="!showOutputLogs && process?._links?.output?.href != undefined" id="showOutputButton"
class="btn btn-primary" (click)="showProcessOutputLogs()">
{{ 'process.detail.logs.button' | translate }}
</button>
<ds-themed-loading *ngIf="retrievingOutputLogs$ | async" class="ds-themed-loading"
message="{{ 'process.detail.logs.loading' | translate }}"></ds-themed-loading>
<pre class="font-weight-bold text-secondary bg-light p-3"
*ngIf="showOutputLogs && (outputLogs$ | async)?.length > 0">{{ (outputLogs$ | async) }}</pre>
<p id="no-output-logs-message" *ngIf="(!(retrievingOutputLogs$ | async) && showOutputLogs)
&& !(outputLogs$ | async) || (outputLogs$ | async)?.length == 0 || !process._links.output">
{{ 'process.detail.logs.none' | translate }}
</p>
{{ 'process.detail.logs.none' | translate }}
</p>
</ds-process-detail-field>
<ds-process-detail-field id="process-actions" [title]="'process.detail.actions'">
<button class="btn btn-success mr-2" routerLink="/processes/new" [queryParams]="{id: process?.processId}"><i
class="fas fa-plus pr-2"></i>{{'process.detail.create' | translate}}</button>
<button *ngIf="isProcessFinished(process)" id="delete" class="btn btn-danger"
(click)="openDeleteModal(deleteModal)">
<i class="fas fa-trash pr-2"></i>{{ 'process.detail.delete.button' | translate }}
</button>
</ds-process-detail-field>
<div style="text-align: right;">
<a class="btn btn-outline-secondary mt-3" [routerLink]="'/processes'">{{'process.detail.back' | translate}}</a>
<a class="btn btn-outline-secondary mt-3" [routerLink]="'/processes'">{{'process.detail.back' | translate}}</a>
</div>
</div>
<ng-template #deleteModal >
<div *ngVar="(processRD$ | async)?.payload as process">
<div class="modal-header">
<div>
<h4>{{'process.detail.delete.header' | translate }}</h4>
</div>
<button type="button" class="close"
(click)="closeModal()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<div>{{'process.detail.delete.body' | translate }}</div>
<div class="mt-4">
<button class="btn btn-primary mr-2" (click)="closeModal()">{{'process.detail.delete.cancel' | translate}}</button>
<button id="delete-confirm" class="btn btn-danger"
(click)="deleteProcess(process)">{{ 'process.detail.delete.confirm' | translate }}
</button>
</div>
</div>
</div>
</ng-template>

View File

@@ -19,15 +19,23 @@ import { RouterTestingModule } from '@angular/router/testing';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ProcessDetailFieldComponent } from './process-detail-field/process-detail-field.component';
import { Process } from '../processes/process.model';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { of as observableOf } from 'rxjs';
import { By } from '@angular/platform-browser';
import { FileSizePipe } from '../../shared/utils/file-size-pipe';
import { Bitstream } from '../../core/shared/bitstream.model';
import { ProcessDataService } from '../../core/data/processes/process-data.service';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { getProcessListRoute } from '../process-page-routing.paths';
describe('ProcessDetailComponent', () => {
let component: ProcessDetailComponent;
@@ -44,6 +52,11 @@ describe('ProcessDetailComponent', () => {
let processOutput;
let modalService;
let notificationsService;
let router;
function init() {
processOutput = 'Process Started';
process = Object.assign(new Process(), {
@@ -93,7 +106,8 @@ describe('ProcessDetailComponent', () => {
}
});
processService = jasmine.createSpyObj('processService', {
getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files))
getFiles: createSuccessfulRemoteDataObject$(createPaginatedList(files)),
delete: createSuccessfulRemoteDataObject$(null)
});
bitstreamDataService = jasmine.createSpyObj('bitstreamDataService', {
findByHref: createSuccessfulRemoteDataObject$(logBitstream)
@@ -104,13 +118,23 @@ describe('ProcessDetailComponent', () => {
httpClient = jasmine.createSpyObj('httpClient', {
get: observableOf(processOutput)
});
modalService = jasmine.createSpyObj('modalService', {
open: {}
});
notificationsService = new NotificationsServiceStub();
router = jasmine.createSpyObj('router', {
navigateByUrl:{}
});
}
beforeEach(waitForAsync(() => {
init();
TestBed.configureTestingModule({
declarations: [ProcessDetailComponent, ProcessDetailFieldComponent, VarDirective, FileSizePipe],
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([])],
imports: [TranslateModule.forRoot()],
providers: [
{
provide: ActivatedRoute,
@@ -121,6 +145,9 @@ describe('ProcessDetailComponent', () => {
{ provide: DSONameService, useValue: nameService },
{ provide: AuthService, useValue: new AuthServiceMock() },
{ provide: HttpClient, useValue: httpClient },
{ provide: NgbModal, useValue: modalService },
{ provide: NotificationsService, useValue: notificationsService },
{ provide: Router, useValue: router },
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
@@ -207,4 +234,34 @@ describe('ProcessDetailComponent', () => {
});
});
describe('openDeleteModal', () => {
it('should open the modal', () => {
component.openDeleteModal({});
expect(modalService.open).toHaveBeenCalledWith({});
});
});
describe('deleteProcess', () => {
it('should delete the process and navigate back to the overview page on success', () => {
spyOn(component, 'closeModal');
component.deleteProcess(process);
expect(processService.delete).toHaveBeenCalledWith(process.processId);
expect(notificationsService.success).toHaveBeenCalled();
expect(component.closeModal).toHaveBeenCalled();
expect(router.navigateByUrl).toHaveBeenCalledWith(getProcessListRoute());
});
it('should delete the process and not navigate on error', () => {
(processService.delete as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$());
spyOn(component, 'closeModal');
component.deleteProcess(process);
expect(processService.delete).toHaveBeenCalledWith(process.processId);
expect(notificationsService.error).toHaveBeenCalled();
expect(component.closeModal).not.toHaveBeenCalled();
expect(router.navigateByUrl).not.toHaveBeenCalled();
});
});
});

View File

@@ -12,8 +12,9 @@ import { RemoteData } from '../../core/data/remote-data';
import { Bitstream } from '../../core/shared/bitstream.model';
import { DSpaceObject } from '../../core/shared/dspace-object.model';
import {
getFirstSucceededRemoteDataPayload,
getFirstSucceededRemoteData
getFirstCompletedRemoteData,
getFirstSucceededRemoteData,
getFirstSucceededRemoteDataPayload
} from '../../core/shared/operators';
import { URLCombiner } from '../../core/url-combiner/url-combiner';
import { AlertType } from '../../shared/alert/aletr-type';
@@ -21,6 +22,10 @@ import { hasValue } from '../../shared/empty.util';
import { ProcessStatus } from '../processes/process-status.model';
import { Process } from '../processes/process.model';
import { redirectOn4xx } from '../../core/shared/authorized.operators';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { getProcessListRoute } from '../process-page-routing.paths';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'ds-process-detail',
@@ -71,6 +76,11 @@ export class ProcessDetailComponent implements OnInit {
*/
dateFormat = 'yyyy-MM-dd HH:mm:ss ZZZZ';
/**
* Reference to NgbModal
*/
protected modalRef: NgbModalRef;
constructor(protected route: ActivatedRoute,
protected router: Router,
protected processService: ProcessDataService,
@@ -78,7 +88,11 @@ export class ProcessDetailComponent implements OnInit {
protected nameService: DSONameService,
private zone: NgZone,
protected authService: AuthService,
protected http: HttpClient) {
protected http: HttpClient,
protected modalService: NgbModal,
protected notificationsService: NotificationsService,
protected translateService: TranslateService
) {
}
/**
@@ -172,4 +186,36 @@ export class ProcessDetailComponent implements OnInit {
|| process.processStatus.toString() === ProcessStatus[ProcessStatus.FAILED].toString()));
}
/**
* Delete the current process
* @param process
*/
deleteProcess(process: Process) {
this.processService.delete(process.processId).pipe(
getFirstCompletedRemoteData()
).subscribe((rd) => {
if (rd.hasSucceeded) {
this.notificationsService.success(this.translateService.get('process.detail.delete.success'));
this.closeModal();
this.router.navigateByUrl(getProcessListRoute());
} else {
this.notificationsService.error(this.translateService.get('process.detail.delete.error'));
}
});
}
/**
* Open a given modal.
* @param content - the modal content.
*/
openDeleteModal(content) {
this.modalRef = this.modalService.open(content);
}
/**
* Close the modal.
*/
closeModal() {
this.modalRef.close();
}
}

View File

@@ -0,0 +1,149 @@
import { ProcessBulkDeleteService } from './process-bulk-delete.service';
import { waitForAsync } from '@angular/core/testing';
import { createFailedRemoteDataObject$, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
import { getMockTranslateService } from '../../shared/mocks/translate.service.mock';
describe('ProcessBulkDeleteService', () => {
let service: ProcessBulkDeleteService;
let processDataService;
let notificationsService;
let mockTranslateService;
beforeEach(waitForAsync(() => {
processDataService = jasmine.createSpyObj('processDataService', {
delete: createSuccessfulRemoteDataObject$(null)
});
notificationsService = new NotificationsServiceStub();
mockTranslateService = getMockTranslateService();
service = new ProcessBulkDeleteService(processDataService, notificationsService, mockTranslateService);
}));
describe('toggleDelete', () => {
it('should add a new value to the processesToDelete list when not yet present', () => {
service.toggleDelete('test-id-1');
service.toggleDelete('test-id-2');
expect(service.processesToDelete).toEqual(['test-id-1', 'test-id-2']);
});
it('should remove a value from the processesToDelete list when already present', () => {
service.toggleDelete('test-id-1');
service.toggleDelete('test-id-2');
expect(service.processesToDelete).toEqual(['test-id-1', 'test-id-2']);
service.toggleDelete('test-id-1');
expect(service.processesToDelete).toEqual(['test-id-2']);
});
});
describe('isToBeDeleted', () => {
it('should return true when the provided process id is present in the list', () => {
service.toggleDelete('test-id-1');
service.toggleDelete('test-id-2');
expect(service.isToBeDeleted('test-id-1')).toBeTrue();
});
it('should return false when the provided process id is not present in the list', () => {
service.toggleDelete('test-id-1');
service.toggleDelete('test-id-2');
expect(service.isToBeDeleted('test-id-3')).toBeFalse();
});
});
describe('clearAllProcesses', () => {
it('should clear the list of to be deleted processes', () => {
service.toggleDelete('test-id-1');
service.toggleDelete('test-id-2');
expect(service.processesToDelete).toEqual(['test-id-1', 'test-id-2']);
service.clearAllProcesses();
expect(service.processesToDelete).toEqual([]);
});
});
describe('getAmountOfSelectedProcesses', () => {
it('should return the amount of the currently selected processes for deletion', () => {
service.toggleDelete('test-id-1');
service.toggleDelete('test-id-2');
expect(service.getAmountOfSelectedProcesses()).toEqual(2);
});
});
describe('isProcessing$', () => {
it('should return a behavior subject containing whether a delete is currently processing or not', () => {
const result = service.isProcessing$();
expect(result.getValue()).toBeFalse();
result.next(true);
expect(result.getValue()).toBeTrue();
});
});
describe('hasSelected', () => {
it('should return if the list of selected processes has values', () => {
expect(service.hasSelected()).toBeFalse();
service.toggleDelete('test-id-1');
service.toggleDelete('test-id-2');
expect(service.hasSelected()).toBeTrue();
});
});
describe('deleteSelectedProcesses', () => {
it('should delete all selected processes, show an error for each failed one and a notification at the end with the amount of succeeded deletions', () => {
(processDataService.delete as jasmine.Spy).and.callFake((processId: string) => {
if (processId.includes('error')) {
return createFailedRemoteDataObject$();
} else {
return createSuccessfulRemoteDataObject$(null);
}
});
service.toggleDelete('test-id-1');
service.toggleDelete('test-id-2');
service.toggleDelete('error-id-3');
service.toggleDelete('test-id-4');
service.toggleDelete('error-id-5');
service.toggleDelete('error-id-6');
service.toggleDelete('test-id-7');
service.deleteSelectedProcesses();
expect(processDataService.delete).toHaveBeenCalledWith('test-id-1');
expect(processDataService.delete).toHaveBeenCalledWith('test-id-2');
expect(processDataService.delete).toHaveBeenCalledWith('error-id-3');
expect(notificationsService.error).toHaveBeenCalled();
expect(mockTranslateService.get).toHaveBeenCalledWith('process.bulk.delete.error.body', {processId: 'error-id-3'});
expect(processDataService.delete).toHaveBeenCalledWith('test-id-4');
expect(processDataService.delete).toHaveBeenCalledWith('error-id-5');
expect(notificationsService.error).toHaveBeenCalled();
expect(mockTranslateService.get).toHaveBeenCalledWith('process.bulk.delete.error.body', {processId: 'error-id-5'});
expect(processDataService.delete).toHaveBeenCalledWith('error-id-6');
expect(notificationsService.error).toHaveBeenCalled();
expect(mockTranslateService.get).toHaveBeenCalledWith('process.bulk.delete.error.body', {processId: 'error-id-6'});
expect(processDataService.delete).toHaveBeenCalledWith('test-id-7');
expect(notificationsService.success).toHaveBeenCalled();
expect(mockTranslateService.get).toHaveBeenCalledWith('process.bulk.delete.success', {count: 4});
expect(service.processesToDelete).toEqual(['error-id-3', 'error-id-5', 'error-id-6']);
});
});
});

View File

@@ -0,0 +1,118 @@
import { Process } from '../processes/process.model';
import { Injectable } from '@angular/core';
import { ProcessDataService } from '../../core/data/processes/process-data.service';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { isNotEmpty } from '../../shared/empty.util';
import { BehaviorSubject, count, from } from 'rxjs';
import { getFirstCompletedRemoteData } from '../../core/shared/operators';
import { concatMap, filter, tap } from 'rxjs/operators';
import { RemoteData } from '../../core/data/remote-data';
import { TranslateService } from '@ngx-translate/core';
@Injectable({
providedIn: 'root'
})
/**
* Service to facilitate removing processes in bulk.
*/
export class ProcessBulkDeleteService {
/**
* Array to track the processes to be deleted
*/
processesToDelete: string[] = [];
/**
* Behavior subject to track whether the delete is processing
* @protected
*/
protected isProcessingBehaviorSubject: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);
constructor(
protected processDataService: ProcessDataService,
protected notificationsService: NotificationsService,
protected translateService: TranslateService
) {
}
/**
* Add or remove a process id to/from the list
* If the id is already present it will be removed, otherwise it will be added.
*
* @param processId - The process id to add or remove
*/
toggleDelete(processId: string) {
if (this.isToBeDeleted(processId)) {
this.processesToDelete.splice(this.processesToDelete.indexOf(processId), 1);
} else {
this.processesToDelete.push(processId);
}
}
/**
* Checks if the provided process id is present in the to be deleted list
* @param processId
*/
isToBeDeleted(processId: string) {
return this.processesToDelete.includes(processId);
}
/**
* Clear the list of processes to be deleted
*/
clearAllProcesses() {
this.processesToDelete.splice(0);
}
/**
* Get the amount of processes selected for deletion
*/
getAmountOfSelectedProcesses() {
return this.processesToDelete.length;
}
/**
* Returns a behavior subject to indicate whether the bulk delete is processing
*/
isProcessing$() {
return this.isProcessingBehaviorSubject;
}
/**
* Returns whether there currently are values selected for deletion
*/
hasSelected(): boolean {
return isNotEmpty(this.processesToDelete);
}
/**
* Delete all selected processes one by one
* When the deletion for a process fails, an error notification will be shown with the process id,
* but it will continue deleting the other processes.
* At the end it will show a notification stating the amount of successful deletes
* The successfully deleted processes will be removed from the list of selected values, the failed ones will be retained.
*/
deleteSelectedProcesses() {
this.isProcessingBehaviorSubject.next(true);
from([...this.processesToDelete]).pipe(
concatMap((processId) => {
return this.processDataService.delete(processId).pipe(
getFirstCompletedRemoteData(),
tap((rd: RemoteData<Process>) => {
if (rd.hasFailed) {
this.notificationsService.error(this.translateService.get('process.bulk.delete.error.head'), this.translateService.get('process.bulk.delete.error.body', {processId: processId}));
} else {
this.toggleDelete(processId);
}
})
);
}),
filter((rd: RemoteData<Process>) => rd.hasSucceeded),
count(),
).subscribe((value) => {
this.notificationsService.success(this.translateService.get('process.bulk.delete.success', {count: value}));
this.isProcessingBehaviorSubject.next(false);
});
}
}

View File

@@ -1,7 +1,19 @@
<div class="container">
<div class="d-flex">
<h2 class="flex-grow-1">{{'process.overview.title' | translate}}</h2>
<button class="btn btn-lg btn-success " routerLink="/processes/new"><i class="fas fa-plus pr-2"></i>{{'process.overview.new' | translate}}</button>
</div>
<div class="d-flex justify-content-end">
<button *ngIf="processBulkDeleteService.hasSelected()" class="btn btn-primary mr-2"
(click)="processBulkDeleteService.clearAllProcesses()"><i
class="fas fa-undo pr-2"></i>{{'process.overview.delete.clear' | translate }}
</button>
<button *ngIf="processBulkDeleteService.hasSelected()" class="btn btn-danger mr-2"
(click)="openDeleteModal(deleteModal)"><i
class="fas fa-trash pr-2"></i>{{'process.overview.delete' | translate: {count: processBulkDeleteService.getAmountOfSelectedProcesses()} }}
</button>
<button class="btn btn-success" routerLink="/processes/new"><i
class="fas fa-plus pr-2"></i>{{'process.overview.new' | translate}}</button>
</div>
<ds-pagination *ngIf="(processesRD$ | async)?.payload?.totalElements > 0"
[paginationOptions]="pageConfig"
@@ -19,19 +31,61 @@
<th scope="col">{{'process.overview.table.start' | translate}}</th>
<th scope="col">{{'process.overview.table.finish' | translate}}</th>
<th scope="col">{{'process.overview.table.status' | translate}}</th>
<th scope="col">{{'process.overview.table.actions' | translate}}</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let process of (processesRD$ | async)?.payload?.page">
<tr *ngFor="let process of (processesRD$ | async)?.payload?.page"
[class.table-danger]="processBulkDeleteService.isToBeDeleted(process.processId)">
<td><a [routerLink]="['/processes/', process.processId]">{{process.processId}}</a></td>
<td><a [routerLink]="['/processes/', process.processId]">{{process.scriptName}}</a></td>
<td *ngVar="(getEpersonName(process.userId) | async) as ePersonName">{{ePersonName}}</td>
<td>{{process.startTime | date:dateFormat:'UTC'}}</td>
<td>{{process.endTime | date:dateFormat:'UTC'}}</td>
<td>{{process.processStatus}}</td>
<td>
<button class="btn btn-outline-danger"
(click)="processBulkDeleteService.toggleDelete(process.processId)"><i
class="fas fa-trash"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</ds-pagination>
</div>
<ng-template #deleteModal>
<div>
<div class="modal-header">
<div>
<h4>{{'process.overview.delete.header' | translate }}</h4>
</div>
<button type="button" class="close"
(click)="closeModal()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<div *ngIf="!(processBulkDeleteService.isProcessing$() |async)">{{'process.overview.delete.body' | translate: {count: processBulkDeleteService.getAmountOfSelectedProcesses()} }}</div>
<div *ngIf="processBulkDeleteService.isProcessing$() |async" class="alert alert-info">
<span class="spinner-border spinner-border-sm spinner-button" role="status" aria-hidden="true"></span>
<span> {{ 'process.overview.delete.processing' | translate: {count: processBulkDeleteService.getAmountOfSelectedProcesses()} }}</span>
</div>
<div class="mt-4">
<button class="btn btn-primary mr-2" [disabled]="processBulkDeleteService.isProcessing$() |async"
(click)="closeModal()">{{'process.detail.delete.cancel' | translate}}</button>
<button id="delete-confirm" class="btn btn-danger"
[disabled]="processBulkDeleteService.isProcessing$() |async"
(click)="deleteSelected()">{{ 'process.overview.delete' | translate: {count: processBulkDeleteService.getAmountOfSelectedProcesses()} }}
</button>
</div>
</div>
</div>
</ng-template>

View File

@@ -1,5 +1,5 @@
import { ProcessOverviewComponent } from './process-overview.component';
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { VarDirective } from '../../shared/utils/var.directive';
import { TranslateModule } from '@ngx-translate/core';
import { RouterTestingModule } from '@angular/router/testing';
@@ -13,11 +13,11 @@ import { ProcessStatus } from '../processes/process-status.model';
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import { createPaginatedList } from '../../shared/testing/utils.test';
import { PaginationService } from '../../core/pagination/pagination.service';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { SortDirection, SortOptions } from '../../core/cache/models/sort-options.model';
import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
import { FindListOptions } from '../../core/data/find-list-options.model';
import { DatePipe } from '@angular/common';
import { BehaviorSubject } from 'rxjs';
import { ProcessBulkDeleteService } from './process-bulk-delete.service';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
describe('ProcessOverviewComponent', () => {
let component: ProcessOverviewComponent;
@@ -30,6 +30,9 @@ describe('ProcessOverviewComponent', () => {
let processes: Process[];
let ePerson: EPerson;
let processBulkDeleteService;
let modalService;
const pipe = new DatePipe('en-US');
function init() {
@@ -80,6 +83,29 @@ describe('ProcessOverviewComponent', () => {
});
paginationService = new PaginationServiceStub();
processBulkDeleteService = jasmine.createSpyObj('processBulkDeleteService', {
clearAllProcesses: {},
deleteSelectedProcesses: {},
isProcessing$: new BehaviorSubject(false),
hasSelected: true,
isToBeDeleted: true,
toggleDelete: {},
getAmountOfSelectedProcesses: 5
});
(processBulkDeleteService.isToBeDeleted as jasmine.Spy).and.callFake((id) => {
if (id === 2) {
return true;
} else {
return false;
}
});
modalService = jasmine.createSpyObj('modalService', {
open: {}
});
}
beforeEach(waitForAsync(() => {
@@ -90,7 +116,9 @@ describe('ProcessOverviewComponent', () => {
providers: [
{ provide: ProcessDataService, useValue: processService },
{ provide: EPersonDataService, useValue: ePersonService },
{ provide: PaginationService, useValue: paginationService }
{ provide: PaginationService, useValue: paginationService },
{ provide: ProcessBulkDeleteService, useValue: processBulkDeleteService },
{ provide: NgbModal, useValue: modalService },
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
@@ -154,5 +182,71 @@ describe('ProcessOverviewComponent', () => {
expect(el.textContent).toContain(processes[index].processStatus);
});
});
it('should display a delete button in the seventh column', () => {
rowElements.forEach((rowElement, index) => {
const el = rowElement.query(By.css('td:nth-child(7)'));
expect(el.nativeElement.innerHTML).toContain('fas fa-trash');
el.query(By.css('button')).triggerEventHandler('click', null);
expect(processBulkDeleteService.toggleDelete).toHaveBeenCalledWith(processes[index].processId);
});
});
it('should indicate a row that has been selected for deletion', () => {
const deleteRow = fixture.debugElement.query(By.css('.table-danger'));
expect(deleteRow.nativeElement.innerHTML).toContain('/processes/' + processes[1].processId);
});
});
describe('overview buttons', () => {
it('should show a button to clear selected processes when there are selected processes', () => {
const clearButton = fixture.debugElement.query(By.css('.btn-primary'));
expect(clearButton.nativeElement.innerHTML).toContain('process.overview.delete.clear');
clearButton.triggerEventHandler('click', null);
expect(processBulkDeleteService.clearAllProcesses).toHaveBeenCalled();
});
it('should not show a button to clear selected processes when there are no selected processes', () => {
(processBulkDeleteService.hasSelected as jasmine.Spy).and.returnValue(false);
fixture.detectChanges();
const clearButton = fixture.debugElement.query(By.css('.btn-primary'));
expect(clearButton).toBeNull();
});
it('should show a button to open the delete modal when there are selected processes', () => {
spyOn(component, 'openDeleteModal');
const deleteButton = fixture.debugElement.query(By.css('.btn-danger'));
expect(deleteButton.nativeElement.innerHTML).toContain('process.overview.delete');
deleteButton.triggerEventHandler('click', null);
expect(component.openDeleteModal).toHaveBeenCalled();
});
it('should not show a button to clear selected processes when there are no selected processes', () => {
(processBulkDeleteService.hasSelected as jasmine.Spy).and.returnValue(false);
fixture.detectChanges();
const deleteButton = fixture.debugElement.query(By.css('.btn-danger'));
expect(deleteButton).toBeNull();
});
});
describe('openDeleteModal', () => {
it('should open the modal', () => {
component.openDeleteModal({});
expect(modalService.open).toHaveBeenCalledWith({});
});
});
describe('deleteSelected', () => {
it('should call the deleteSelectedProcesses method on the processBulkDeleteService and close the modal when processing is done', () => {
spyOn(component, 'closeModal');
spyOn(component, 'setProcesses');
component.deleteSelected();
expect(processBulkDeleteService.deleteSelectedProcesses).toHaveBeenCalled();
expect(component.closeModal).toHaveBeenCalled();
expect(component.setProcesses).toHaveBeenCalled();
});
});
});

View File

@@ -1,5 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Observable, Subscription } from 'rxjs';
import { RemoteData } from '../../core/data/remote-data';
import { PaginatedList } from '../../core/data/paginated-list.model';
import { Process } from '../processes/process.model';
@@ -11,6 +11,9 @@ import { map, switchMap } from 'rxjs/operators';
import { ProcessDataService } from '../../core/data/processes/process-data.service';
import { PaginationService } from '../../core/pagination/pagination.service';
import { FindListOptions } from '../../core/data/find-list-options.model';
import { ProcessBulkDeleteService } from './process-bulk-delete.service';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { hasValue } from '../../shared/empty.util';
@Component({
selector: 'ds-process-overview',
@@ -19,7 +22,7 @@ import { FindListOptions } from '../../core/data/find-list-options.model';
/**
* Component displaying a list of all processes in a paginated table
*/
export class ProcessOverviewComponent implements OnInit {
export class ProcessOverviewComponent implements OnInit, OnDestroy {
/**
* List of all processes
@@ -46,13 +49,22 @@ export class ProcessOverviewComponent implements OnInit {
*/
dateFormat = 'yyyy-MM-dd HH:mm:ss';
processesToDelete: string[] = [];
private modalRef: any;
isProcessingSub: Subscription;
constructor(protected processService: ProcessDataService,
protected paginationService: PaginationService,
protected ePersonService: EPersonDataService) {
protected ePersonService: EPersonDataService,
protected modalService: NgbModal,
public processBulkDeleteService: ProcessBulkDeleteService,
) {
}
ngOnInit(): void {
this.setProcesses();
this.processBulkDeleteService.clearAllProcesses();
}
/**
@@ -60,7 +72,7 @@ export class ProcessOverviewComponent implements OnInit {
*/
setProcesses() {
this.processesRD$ = this.paginationService.getFindListOptions(this.pageConfig.id, this.config).pipe(
switchMap((config) => this.processService.findAll(config))
switchMap((config) => this.processService.findAll(config, true, false))
);
}
@@ -74,8 +86,46 @@ export class ProcessOverviewComponent implements OnInit {
map((eperson: EPerson) => eperson.name)
);
}
ngOnDestroy(): void {
this.paginationService.clearPagination(this.pageConfig.id);
if (hasValue(this.isProcessingSub)) {
this.isProcessingSub.unsubscribe();
}
}
/**
* Open a given modal.
* @param content - the modal content.
*/
openDeleteModal(content) {
this.modalRef = this.modalService.open(content);
}
/**
* Close the modal.
*/
closeModal() {
this.modalRef.close();
}
/**
* Delete the previously selected processes using the processBulkDeleteService
* After the deletion has started, subscribe to the isProcessing$ and when it is set
* to false after the processing is done, close the modal and reinitialise the processes
*/
deleteSelected() {
this.processBulkDeleteService.deleteSelectedProcesses();
if (hasValue(this.isProcessingSub)) {
this.isProcessingSub.unsubscribe();
}
this.isProcessingSub = this.processBulkDeleteService.isProcessing$()
.subscribe((isProcessing) => {
if (!isProcessing) {
this.closeModal();
this.setProcesses();
}
});
}
}

View File

@@ -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<CreateCollectionParentSelectorComponent> {
protected getComponentName(): string {
return 'CreateCollectionParentSelectorComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/create-collection-parent-selector/create-collection-parent-selector.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./create-collection-parent-selector.component');
}
}

View File

@@ -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<CreateCommunityParentSelectorComponent> {
protected getComponentName(): string {
return 'CreateCommunityParentSelectorComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/create-community-parent-selector/create-community-parent-selector.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./create-community-parent-selector.component');
}
}

View File

@@ -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<CreateItemParentSelectorComponent> {
@Input() entityType: string;
protected inAndOutputNames: (keyof CreateItemParentSelectorComponent & keyof this)[] = ['entityType'];
protected getComponentName(): string {
return 'CreateItemParentSelectorComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/create-item-parent-selector/create-item-parent-selector.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./create-item-parent-selector.component');
}
}

View File

@@ -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<EditCollectionSelectorComponent> {
protected getComponentName(): string {
return 'EditCollectionSelectorComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/edit-collection-selector/edit-collection-selector.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./edit-collection-selector.component');
}
}

View File

@@ -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<EditCommunitySelectorComponent> {
protected getComponentName(): string {
return 'EditCommunitySelectorComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/edit-community-selector/edit-community-selector.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./edit-community-selector.component');
}
}

View File

@@ -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<EditItemSelectorComponent> {
protected getComponentName(): string {
return 'EditItemSelectorComponent';
}
protected importThemedComponent(themeName: string): Promise<any> {
return import(`../../../../../themes/${themeName}/app/shared/dso-selector/modal-wrappers/edit-item-selector/edit-item-selector.component`);
}
protected importUnthemedComponent(): Promise<any> {
return import('./edit-item-selector.component');
}
}

View File

@@ -8,6 +8,7 @@ export function getMockThemeService(themeName = 'base', themes?: ThemeConfig[]):
getThemeName: themeName,
getThemeName$: observableOf(themeName),
getThemeConfigFor: undefined,
listenForRouteChanges: undefined,
});
if (isNotEmpty(themes)) {

View File

@@ -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';
@@ -395,11 +413,17 @@ const COMPONENTS = [
DsoInputSuggestionsComponent,
DSOSelectorComponent,
CreateCommunityParentSelectorComponent,
ThemedCreateCommunityParentSelectorComponent,
CreateCollectionParentSelectorComponent,
ThemedCreateCollectionParentSelectorComponent,
CreateItemParentSelectorComponent,
ThemedCreateItemParentSelectorComponent,
EditCommunitySelectorComponent,
ThemedEditCommunitySelectorComponent,
EditCollectionSelectorComponent,
ThemedEditCollectionSelectorComponent,
EditItemSelectorComponent,
ThemedEditItemSelectorComponent,
CommunitySearchResultListElementComponent,
CollectionSearchResultListElementComponent,
BrowseByComponent,
@@ -491,11 +515,17 @@ const ENTRY_COMPONENTS = [
StartsWithDateComponent,
StartsWithTextComponent,
CreateCommunityParentSelectorComponent,
ThemedCreateCommunityParentSelectorComponent,
CreateCollectionParentSelectorComponent,
ThemedCreateCollectionParentSelectorComponent,
CreateItemParentSelectorComponent,
ThemedCreateItemParentSelectorComponent,
EditCommunitySelectorComponent,
ThemedEditCommunitySelectorComponent,
EditCollectionSelectorComponent,
ThemedEditCollectionSelectorComponent,
EditItemSelectorComponent,
ThemedEditItemSelectorComponent,
PlainTextMetadataListElementComponent,
ItemMetadataListElementComponent,
MetadataRepresentationListElementComponent,

View File

@@ -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);
});
});
});

View File

@@ -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<ThemeState>('theme');
@@ -42,11 +46,16 @@ export class ThemeService {
*/
hasDynamicTheme: boolean;
private _isThemeLoading$ = new BehaviorSubject<boolean>(false);
private _isThemeCSSLoading$ = new BehaviorSubject<boolean>(false);
constructor(
private store: Store<ThemeState>,
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<string> {
return this.store.pipe(
select(currentThemeSelector)
);
}
/**
* Whether the theme is currently loading
*/
get isThemeLoading$(): Observable<boolean> {
return this._isThemeLoading$;
}
/**
* Every time the theme is changed
* - if the theme name is valid, load it (CSS + <head> 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
* - <head> tags
* @param themeName
* @private
*/
private loadGlobalThemeConfig(themeName: string): void {
this.setThemeCss(themeName);
this.setHeadTags(themeName);
}
/**
* Update the theme css file in <head>
*
* @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 <head> 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 <head> 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 <head> tag element
* @param headTagConfig the configuration for this <head> 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

View File

@@ -24,9 +24,12 @@
</ds-viewable-collection>
<ds-themed-loading *ngIf="(isLoading$ | async)"
message="{{'loading.search-results' | translate}}"></ds-themed-loading>
<div *ngIf="!(isLoading$ | async) && entriesRD?.payload?.page?.length === 0" id="empty-external-entry-list">
<div *ngIf="!(isLoading$ | async) && entriesRD?.payload?.page?.length === 0" data-test="empty-external-entry-list">
<ds-alert [type]="'alert-info'">{{ 'search.results.empty' | translate }}</ds-alert>
</div>
<div *ngIf="!(isLoading$ | async) && entriesRD.statusCode === 500" data-test="empty-external-error-500">
<ds-alert [type]="'alert-info'">{{ 'search.results.response.500' | translate }}</ds-alert>
</div>
</ng-container>
</div>
<div *ngIf="reload$.value.sourceId === ''" class="col-md-12">

View File

@@ -19,9 +19,15 @@ import { VarDirective } from '../../shared/utils/var.directive';
import { routeServiceStub } from '../../shared/testing/route-service.stub';
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model';
import { createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
import {
createFailedRemoteDataObject$,
createSuccessfulRemoteDataObject,
createSuccessfulRemoteDataObject$
} from '../../shared/remote-data.utils';
import { ExternalSourceEntry } from '../../core/shared/external-source-entry.model';
import { SubmissionImportExternalPreviewComponent } from './import-external-preview/submission-import-external-preview.component';
import { By } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
describe('SubmissionImportExternalComponent test suite', () => {
let comp: SubmissionImportExternalComponent;
@@ -44,7 +50,8 @@ describe('SubmissionImportExternalComponent test suite', () => {
beforeEach(waitForAsync (() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot()
TranslateModule.forRoot(),
BrowserAnimationsModule
],
declarations: [
SubmissionImportExternalComponent,
@@ -177,6 +184,326 @@ describe('SubmissionImportExternalComponent test suite', () => {
});
});
describe('handle backend response for search query', () => {
const paginatedData: any = {
'timeCompleted': 1657009282990,
'msToLive': 900000,
'lastUpdated': 1657009282990,
'state': 'Success',
'errorMessage': null,
'payload': {
'type': {
'value': 'paginated-list'
},
'pageInfo': {
'elementsPerPage': 10,
'totalElements': 11971608,
'totalPages': 1197161,
'currentPage': 1
},
'_links': {
'first': {
'href': 'https://example.com/server/api/integration/externalsources/scopus/entries?query=test&page=0&size=10&sort=id,asc'
},
'self': {
'href': 'https://example.com/server/api/integration/externalsources/scopus/entries?sort=id,ASC&page=0&size=10&query=test'
},
'next': {
'href': 'https://example.com/server/api/integration/externalsources/scopus/entries?query=test&page=1&size=10&sort=id,asc'
},
'last': {
'href': 'https://example.com/server/api/integration/externalsources/scopus/entries?query=test&page=1197160&size=10&sort=id,asc'
},
'page': [
{
'href': 'https://example.com/server/api/integration/externalsources/scopus/entryValues/2-s2.0-85130258665'
}
]
},
'page': [
{
'id': '2-s2.0-85130258665',
'type': 'externalSourceEntry',
'display': 'Biological activities of endophytic fungi isolated from Annona muricata Linnaeus: a systematic review',
'value': 'Biological activities of endophytic fungi isolated from Annona muricata Linnaeus: a systematic review',
'externalSource': 'scopus',
'metadata': {
'dc.contributor.author': [
{
'uuid': 'cbceba09-4c12-4968-ab02-2f77a985b422',
'language': null,
'value': 'Silva I.M.M.',
'place': -1,
'authority': null,
'confidence': -1
}
],
'dc.date.issued': [
{
'uuid': 'e8d3c306-ce21-43e2-8a80-5f257cc3b7ea',
'language': null,
'value': '2024-01-01',
'place': -1,
'authority': null,
'confidence': -1
}
],
'dc.description.abstract': [
{
'uuid': 'c9ee4076-c602-4c1d-ab1a-60bbdd0dd511',
'language': null,
'value': 'This systematic review integrates the data available in the literature regarding the biological activities of the extracts of endophytic fungi isolated from Annona muricata and their secondary metabolites. The search was performed using four electronic databases, and studies quality was evaluated using an adapted assessment tool. The initial database search yielded 436 results; ten studies were selected for inclusion. The leaf was the most studied part of the plant (in nine studies); Periconia sp. was the most tested fungus (n = 4); the most evaluated biological activity was anticancer (n = 6), followed by antiviral (n = 3). Antibacterial, antifungal, and antioxidant activities were also tested. Terpenoids or terpenoid hybrid compounds were the most abundant chemical metabolites. Phenolic compounds, esters, alkaloids, saturated and unsaturated fatty acids, aromatic compounds, and peptides were also reported. The selected studies highlighted the biotechnological potentiality of the endophytic fungi extracts from A. muricata. Consequently, it can be considered a promising source of biological compounds with antioxidant effects and active against different microorganisms and cancer cells. Further research is needed involving different plant tissues, other microorganisms, such as SARS-CoV-2, and different cancer cells.',
'place': -1,
'authority': null,
'confidence': -1
}
],
'dc.identifier.doi': [
{
'uuid': '95ec26be-c1b4-4c4a-b12d-12421a4f181d',
'language': null,
'value': '10.1590/1519-6984.259525',
'place': -1,
'authority': null,
'confidence': -1
}
],
'dc.identifier.pmid': [
{
'uuid': 'd6913cd6-1007-4013-b486-3f07192bc739',
'language': null,
'value': '35588520',
'place': -1,
'authority': null,
'confidence': -1
}
],
'dc.identifier.scopus': [
{
'uuid': '6386a1f6-84ba-431d-a583-e16d19af8db0',
'language': null,
'value': '2-s2.0-85130258665',
'place': -1,
'authority': null,
'confidence': -1
}
],
'dc.relation.grantno': [
{
'uuid': 'bcafd7b0-827d-4abb-8608-95dc40a8e58a',
'language': null,
'value': 'undefined',
'place': -1,
'authority': null,
'confidence': -1
}
],
'dc.relation.ispartof': [
{
'uuid': '680819c8-c143-405f-9d09-f84d2d5cd338',
'language': null,
'value': 'Brazilian Journal of Biology',
'place': -1,
'authority': null,
'confidence': -1
}
],
'dc.relation.ispartofseries': [
{
'uuid': '06634104-127b-44f6-9dcc-efae24b74bd1',
'language': null,
'value': 'Brazilian Journal of Biology',
'place': -1,
'authority': null,
'confidence': -1
}
],
'dc.relation.issn': [
{
'uuid': '5f6cce46-2538-49e9-8ed0-a3988dcac6c5',
'language': null,
'value': '15196984',
'place': -1,
'authority': null,
'confidence': -1
}
],
'dc.subject': [
{
'uuid': '0b6fbc77-de54-4f4a-b317-3d74a429f22a',
'language': null,
'value': 'biological products | biotechnology | mycology | soursop',
'place': -1,
'authority': null,
'confidence': -1
}
],
'dc.title': [
{
'uuid': '4c0fa3d3-1a8c-4302-a772-4a4d0408df35',
'language': null,
'value': 'Biological activities of endophytic fungi isolated from Annona muricata Linnaeus: a systematic review',
'place': -1,
'authority': null,
'confidence': -1
}
],
'dc.type': [
{
'uuid': '5b6e0337-6f79-4574-a720-536816d1dc6e',
'language': null,
'value': 'Journal',
'place': -1,
'authority': null,
'confidence': -1
}
],
'oaire.citation.volume': [
{
'uuid': 'b88b0246-61a9-4aca-917f-68afc8ead7d8',
'language': null,
'value': '84',
'place': -1,
'authority': null,
'confidence': -1
}
],
'oairecerif.affiliation.orgunit': [
{
'uuid': '487c0fbc-3622-4cc7-a5fa-4edf780c6a21',
'language': null,
'value': 'Universidade Federal do Reconcavo da Bahia',
'place': -1,
'authority': null,
'confidence': -1
}
],
'oairecerif.citation.number': [
{
'uuid': '90808bdd-f456-4ba3-91aa-b82fb3c453f6',
'language': null,
'value': 'e259525',
'place': -1,
'authority': null,
'confidence': -1
}
],
'person.identifier.orcid': [
{
'uuid': 'e533d0d2-cf26-4c3e-b5ae-cabf497dfb6b',
'language': null,
'value': '#PLACEHOLDER_PARENT_METADATA_VALUE#',
'place': -1,
'authority': null,
'confidence': -1
}
],
'person.identifier.scopus-author-id': [
{
'uuid': '4faf0be5-0226-4d4f-92a0-938397c4ec02',
'language': null,
'value': '42561627000',
'place': -1,
'authority': null,
'confidence': -1
}
]
},
'_links': {
'self': {
'href': 'https://example.com/server/api/integration/externalsources/scopus/entryValues/2-s2.0-85130258665'
}
}
}
]
},
'statusCode': 200
};
const errorObj = {
errorMessage: 'Http failure response for ' +
'https://example.com/server/api/integration/externalsources/pubmed/entries?sort=id,ASC&page=0&size=10&query=test: 500 OK',
statusCode: 500,
timeCompleted: 1656950434666,
errors: [{
'message': 'Internal Server Error', 'paths': ['/server/api/integration/externalsources/pubmed/entries']
}]
};
beforeEach(() => {
fixture = TestBed.createComponent(SubmissionImportExternalComponent);
comp = fixture.componentInstance;
compAsAny = comp;
scheduler = getTestScheduler();
});
afterEach(() => {
fixture.destroy();
comp = null;
compAsAny = null;
});
it('REST endpoint returns a 200 response with valid content', () => {
mockExternalSourceService.getExternalSourceEntries.and.returnValue(createSuccessfulRemoteDataObject$(paginatedData.payload));
const expectedEntries = createSuccessfulRemoteDataObject(paginatedData.payload);
spyOn(routeServiceStub, 'getQueryParameterValue').and.callFake((param) => {
if (param === 'entity') {
return observableOf('Publication');
} else if (param === 'sourceId') {
return observableOf('scopus');
} else if (param === 'query') {
return observableOf('test');
}
return observableOf({});
});
fixture.detectChanges();
expect(comp.isLoading$.value).toBe(false);
expect(comp.entriesRD$.value).toEqual(expectedEntries);
const viewableCollection = fixture.debugElement.query(By.css('ds-viewable-collection'));
expect(viewableCollection).toBeTruthy();
});
it('REST endpoint returns a 200 response with no results', () => {
mockExternalSourceService.getExternalSourceEntries.and.returnValue(createSuccessfulRemoteDataObject$(createPaginatedList([])));
const expectedEntries = createSuccessfulRemoteDataObject(createPaginatedList([]));
spyOn(routeServiceStub, 'getQueryParameterValue').and.callFake((param) => {
if (param === 'entity') {
return observableOf('Publication');
}
return observableOf({});
});
fixture.detectChanges();
expect(comp.isLoading$.value).toBe(false);
expect(comp.entriesRD$.value).toEqual(expectedEntries);
const noDataAlert = fixture.debugElement.query(By.css('[data-test="empty-external-entry-list"]'));
expect(noDataAlert).toBeTruthy();
});
it('REST endpoint returns a 500 error', () => {
mockExternalSourceService.getExternalSourceEntries.and.returnValue(createFailedRemoteDataObject$(
errorObj.errorMessage,
errorObj.statusCode,
errorObj.timeCompleted
));
spyOn(routeServiceStub, 'getQueryParameterValue').and.callFake((param) => {
if (param === 'entity') {
return observableOf('Publication');
} else if (param === 'sourceId') {
return observableOf('pubmed');
} else if (param === 'query') {
return observableOf('test');
}
return observableOf({});
});
fixture.detectChanges();
expect(comp.isLoading$.value).toBe(false);
expect(comp.entriesRD$.value.statusCode).toEqual(500);
const noDataAlert = fixture.debugElement.query(By.css('[data-test="empty-external-error-500"]'));
expect(noDataAlert).toBeTruthy();
});
});
});
// declare a test component

View File

@@ -2983,6 +2983,22 @@
"process.detail.create" : "Create similar process",
"process.detail.actions": "Actions",
"process.detail.delete.button": "Delete process",
"process.detail.delete.header": "Delete process",
"process.detail.delete.body": "Are you sure you want to delete the current process?",
"process.detail.delete.cancel": "Cancel",
"process.detail.delete.confirm": "Delete process",
"process.detail.delete.success": "The process was successfully deleted.",
"process.detail.delete.error": "Something went wrong when deleting the process",
"process.overview.table.finish" : "Finish time (UTC)",
@@ -3003,6 +3019,25 @@
"process.overview.new": "New",
"process.overview.table.actions": "Actions",
"process.overview.delete": "Delete {{count}} processes",
"process.overview.delete.clear": "Clear delete selection",
"process.overview.delete.processing": "{{count}} process(es) are being deleted. Please wait for the deletion to fully complete. Note that this can take a while.",
"process.overview.delete.body": "Are you sure you want to delete {{count}} process(es)?",
"process.overview.delete.header": "Delete processes",
"process.bulk.delete.error.head": "Error on deleteing process",
"process.bulk.delete.error.body": "The process with ID {{processId}} could not be deleted. The remaining processes will continue being deleted. ",
"process.bulk.delete.success": "{{count}} process(es) have been succesfully deleted",
"profile.breadcrumbs": "Update Profile",
@@ -3587,6 +3622,7 @@
"search.results.view-result": "View",
"search.results.response.500": "An error occurred during query execution, please try again later",
"default.search.results.head": "Search Results",

View File

@@ -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<AppConfig>('APP_CONFIG');
const APP_CONFIG_STATE = makeStateKey('APP_CONFIG_STATE');

View File

@@ -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<string>('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<AppConfig>(APP_CONFIG_STATE)) {
const appConfig = transferState.get<AppConfig>(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,

View File

@@ -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);
});
});
});
});
});

View File

@@ -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<AppState>,
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<AppConfig>(APP_CONFIG_STATE)) {
const appConfig = transferState.get<AppConfig>(APP_CONFIG_STATE, new DefaultAppConfig());
// extend environment with app config for browser
extendEnvironmentWithAppConfig(environment, appConfig);
}
}
protected init(): () => Promise<boolean> {
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<boolean> {
const state = this.transferState.get<any>(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();
}
}

View File

@@ -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<AppConfig>(APP_CONFIG_STATE, environment as AppConfig);
dspaceTransferState.transfer();
correlationIdService.initCorrelationId();
return () => true;
},
deps: [TransferState, DSpaceTransferState, CorrelationIdService],
multi: true
},
...ServerInitService.providers(),
{
provide: Angulartics2,
useClass: Angulartics2Mock

View File

@@ -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<AppState>,
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<boolean> {
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<AppConfig>(APP_CONFIG_STATE, environment as AppConfig);
}
}

View File

@@ -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 {
}

View File

@@ -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<boolean> {
const state = this.transferState.get<any>(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();
}
}

View File

@@ -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 {
}

View File

@@ -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<boolean> {
this.transferState.onSerialize(DSpaceTransferState.NGRX_STATE, () => {
let state;
this.store.pipe(take(1)).subscribe((saveState: any) => {
state = saveState;
});
return state;
});
return new Promise<boolean>(() => true);
}
}

View File

@@ -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<AppState>
) {
}
abstract transfer(): Promise<boolean>;
}

View File

@@ -0,0 +1,11 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="close" (click)="close()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<h5 *ngIf="header" class="px-2">{{header | translate}}</h5>
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
</div>
</div>

View File

@@ -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 {
}

View File

@@ -0,0 +1,19 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="close" (click)="close()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<button class="btn btn-outline-primary btn-lg btn-block" (click)="selectObject(undefined)">{{'dso-selector.create.community.top-level' | translate}}</button>
<h3 class="position-relative py-1 my-3 font-weight-normal">
<hr>
<div id="create-community-or-separator" class="text-center position-absolute w-100">
<span class="px-4 bg-white">or</span>
</div>
</h3>
<h5 class="px-2">{{'dso-selector.create.community.sub-level' | translate}}</h5>
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
</div>
</div>

View File

@@ -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 {
}

View File

@@ -0,0 +1,15 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="close" (click)="close()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<div [innerHTML]="'dso-selector.create.item.intro' | translate"></div>
<h5 *ngIf="header" class="px-2">{{header | translate}}</h5>
<ds-authorized-collection-selector [currentDSOId]="dsoRD?.payload.uuid"
[entityType]="entityType"
[types]="selectorTypes"
(onSelect)="selectObject($event)"></ds-authorized-collection-selector>
</div>
</div>

View File

@@ -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 {
}

View File

@@ -0,0 +1,11 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="close" (click)="close()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<h5 *ngIf="header" class="px-2">{{header | translate}}</h5>
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
</div>
</div>

View File

@@ -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 {
}

View File

@@ -0,0 +1,11 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="close" (click)="close()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<h5 *ngIf="header" class="px-2">{{header | translate}}</h5>
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
</div>
</div>

View File

@@ -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 {
}

View File

@@ -0,0 +1,11 @@
<div>
<div class="modal-header">{{'dso-selector.'+ action + '.' + objectType.toString().toLowerCase() + '.head' | translate}}
<button type="button" class="close" (click)="close()" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<h5 *ngIf="header" class="px-2">{{header | translate}}</h5>
<ds-dso-selector [currentDSOId]="dsoRD?.payload.uuid" [types]="selectorTypes" (onSelect)="selectObject($event)"></ds-dso-selector>
</div>
</div>

View File

@@ -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 {
}

View File

@@ -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({