diff --git a/.travis.yml b/.travis.yml index 0d65d76f41..90d50d1b08 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,4 @@ -sudo: required +os: linux dist: bionic language: node_js @@ -35,10 +35,11 @@ before_install: - google-chrome-stable --version install: - # Start up DSpace 7 using the entities database dump + # Start up a test DSpace 7 REST backend using the entities database dump - docker-compose -f ./docker/docker-compose-travis.yml up -d # Use the dspace-cli image to populate the assetstore. Triggers a discovery and oai update - docker-compose -f ./docker/cli.yml -f ./docker/cli.assetstore.yml run --rm dspace-cli + # Install all local dependencies (retry if initially fails) - travis_retry yarn install before_script: @@ -49,9 +50,14 @@ before_script: #- curl http://localhost:8080/server/ script: + # build app and run all tests - yarn run ci - - cat coverage/dspace-angular-cli/lcov.info | ./node_modules/coveralls/bin/coveralls.js after_script: # Shutdown docker after everything runs - docker-compose -f ./docker/docker-compose-travis.yml down + +# After a successful build and test (see 'script'), send code coverage reports to coveralls.io +# These code coverage reports are generated by the coveralls node module in our package.json +after_success: + - cat coverage/dspace-angular-cli/lcov.info | ./node_modules/coveralls/bin/coveralls.js diff --git a/package.json b/package.json index 21a89400bf..0bbf156c9d 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "codelyzer": "^5.0.0", "compression-webpack-plugin": "^3.0.1", "copy-webpack-plugin": "^5.1.1", - "coveralls": "3.0.0", + "coveralls": "^3.0.0", "css-loader": "3.4.0", "cssnano": "^4.1.10", "deep-freeze": "0.0.1", diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 9e24790fa1..da3cf9537b 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,35 +1,22 @@ -import { - async, - ComponentFixture, - inject, - TestBed -} from '@angular/core/testing'; - -import { - CUSTOM_ELEMENTS_SCHEMA, - DebugElement -} from '@angular/core'; - +import { async, ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA, DebugElement } from '@angular/core'; import { CommonModule } from '@angular/common'; - import { By } from '@angular/platform-browser'; +import { ActivatedRoute, Router } from '@angular/router'; -import { TranslateModule, TranslateLoader } from '@ngx-translate/core'; +import { TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { Store, StoreModule } from '@ngrx/store'; +import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; // Load the implementations that should be tested import { AppComponent } from './app.component'; - import { HostWindowState } from './shared/search/host-window.reducer'; import { HostWindowResizeAction } from './shared/host-window.actions'; - import { MetadataService } from './core/metadata/metadata.service'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; - import { TranslateLoaderMock } from './shared/mocks/translate-loader.mock'; import { MetadataServiceMock } from './shared/mocks/metadata-service.mock'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { AngularticsMock } from './shared/mocks/angulartics.service.mock'; import { AuthServiceMock } from './shared/mocks/auth.service.mock'; import { AuthService } from './core/auth/auth.service'; @@ -39,14 +26,12 @@ import { CSSVariableServiceStub } from './shared/testing/css-variable-service.st import { MenuServiceStub } from './shared/testing/menu-service.stub'; import { HostWindowService } from './shared/host-window.service'; import { HostWindowServiceStub } from './shared/testing/host-window-service.stub'; -import { ActivatedRoute, Router } from '@angular/router'; import { RouteService } from './core/services/route.service'; import { MockActivatedRoute } from './shared/mocks/active-router.mock'; import { RouterMock } from './shared/mocks/router.mock'; -import { CookieServiceMock } from './shared/mocks/cookie.service.mock'; -import { CookieService } from './core/services/cookie.service'; import { Angulartics2DSpace } from './statistics/angulartics/dspace-provider'; import { storeModuleConfig } from './app.reducer'; +import { LocaleService } from './core/locale/locale.service'; let comp: AppComponent; let fixture: ComponentFixture; @@ -56,6 +41,12 @@ const menuService = new MenuServiceStub(); describe('App component', () => { + function getMockLocaleService(): LocaleService { + return jasmine.createSpyObj('LocaleService', { + setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode') + }) + } + // async beforeEach beforeEach(async(() => { return TestBed.configureTestingModule({ @@ -81,7 +72,7 @@ describe('App component', () => { { provide: MenuService, useValue: menuService }, { provide: CSSVariableService, useClass: CSSVariableServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(800) }, - { provide: CookieService, useValue: new CookieServiceMock()}, + { provide: LocaleService, useValue: getMockLocaleService() }, AppComponent, RouteService ], diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 832a7b642f..35ca4db131 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,10 +1,19 @@ import { delay, filter, map, take } from 'rxjs/operators'; -import { AfterViewInit, ChangeDetectionStrategy, Component, HostListener, Inject, OnInit, ViewEncapsulation } from '@angular/core'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + HostListener, + Inject, + OnInit, + ViewEncapsulation +} from '@angular/core'; import { NavigationCancel, NavigationEnd, NavigationStart, Router } from '@angular/router'; +import { BehaviorSubject, combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; import { select, Store } from '@ngrx/store'; - import { TranslateService } from '@ngx-translate/core'; +import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import { MetadataService } from './core/metadata/metadata.service'; import { HostWindowResizeAction } from './shared/host-window.actions'; @@ -12,20 +21,17 @@ import { HostWindowState } from './shared/search/host-window.reducer'; import { NativeWindowRef, NativeWindowService } from './core/services/window.service'; import { isAuthenticated } from './core/auth/selectors'; import { AuthService } from './core/auth/auth.service'; -import { Angulartics2GoogleAnalytics } from 'angulartics2/ga'; import variables from '../styles/_exposed_variables.scss'; import { CSSVariableService } from './shared/sass-helper/sass-helper.service'; import { MenuService } from './shared/menu/menu.service'; import { MenuID } from './shared/menu/initial-menus-state'; -import { BehaviorSubject, combineLatest as combineLatestObservable, Observable, of } from 'rxjs'; import { slideSidebarPadding } from './shared/animations/slide'; import { HostWindowService } from './shared/host-window.service'; import { Theme } from '../config/theme.inferface'; -import { isNotEmpty } from './shared/empty.util'; -import { CookieService } from './core/services/cookie.service'; 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'; export const LANG_COOKIE = 'language_cookie'; @@ -59,7 +65,7 @@ export class AppComponent implements OnInit, AfterViewInit { private cssService: CSSVariableService, private menuService: MenuService, private windowService: HostWindowService, - private cookie: CookieService + private localeService: LocaleService ) { /* Use models object so all decorators are actually called */ this.models = models; @@ -67,23 +73,10 @@ export class AppComponent implements OnInit, AfterViewInit { 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); + // translate.setDefaultLang(environment.defaultLanguage); - // Attempt to get the language from a cookie - const lang = cookie.get(LANG_COOKIE); - if (isNotEmpty(lang)) { - // Cookie found - // Use the language from the cookie - translate.use(lang); - } else { - // Cookie not found - // Attempt to get the browser language from the user - if (translate.getLangs().includes(translate.getBrowserLang())) { - translate.use(translate.getBrowserLang()); - } else { - translate.use(environment.defaultLanguage); - } - } + // set the current language code + this.localeService.setCurrentLanguageCode(); angulartics2GoogleAnalytics.startTracking(); angulartics2DSpace.startTracking(); @@ -140,15 +133,15 @@ export class AppComponent implements OnInit, AfterViewInit { // More information on this bug-fix: https://blog.angular-university.io/angular-debugging/ delay(0) ).subscribe((event) => { - if (event instanceof NavigationStart) { - this.isLoading$.next(true); - } else if ( - event instanceof NavigationEnd || - event instanceof NavigationCancel - ) { - this.isLoading$.next(false); - } - }); + if (event instanceof NavigationStart) { + this.isLoading$.next(true); + } else if ( + event instanceof NavigationEnd || + event instanceof NavigationCancel + ) { + this.isLoading$.next(false); + } + }); } @HostListener('window:resize', ['$event']) diff --git a/src/app/core/auth/auth.service.spec.ts b/src/app/core/auth/auth.service.spec.ts index a15d604cc4..7f2c1e29cc 100644 --- a/src/app/core/auth/auth.service.spec.ts +++ b/src/app/core/auth/auth.service.spec.ts @@ -241,6 +241,12 @@ describe('AuthService test', () => { expect(result).toBe(false); }); + it('should return true when authentication is loaded', () => { + authService.isAuthenticationLoaded().subscribe((status: boolean) => { + expect(status).toBe(true); + }); + }); + }); describe('', () => { diff --git a/src/app/core/auth/auth.service.ts b/src/app/core/auth/auth.service.ts index fe9828bc73..f45fb824ff 100644 --- a/src/app/core/auth/auth.service.ts +++ b/src/app/core/auth/auth.service.ts @@ -21,7 +21,8 @@ import { getAuthenticationToken, getRedirectUrl, isAuthenticated, - isTokenRefreshing + isTokenRefreshing, + isAuthenticatedLoaded } from './selectors'; import { AppState, routerStateSelector } from '../../app.reducer'; import { @@ -148,6 +149,14 @@ export class AuthService { return this.store.pipe(select(isAuthenticated)); } + /** + * Determines if authentication is loaded + * @returns {Observable} + */ + public isAuthenticationLoaded(): Observable { + return this.store.pipe(select(isAuthenticatedLoaded)); + } + /** * Returns the href link to authenticated user * @returns {string} @@ -197,7 +206,7 @@ export class AuthService { return this.store.pipe( select(getAuthenticatedUserId), hasValueOperator(), - switchMap((id: string) => this.epersonService.findById(id)), + switchMap((id: string) => { console.log('ID: ', id); return this.epersonService.findById(id) }), getAllSucceededRemoteDataPayload() ) } diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts index e84350902e..58fdac8e54 100644 --- a/src/app/core/core.module.ts +++ b/src/app/core/core.module.ts @@ -144,6 +144,7 @@ import { ScriptDataService } from './data/processes/script-data.service'; import { ProcessFilesResponseParsingService } from './data/process-files-response-parsing.service'; import { WorkflowActionDataService } from './data/workflow-action-data.service'; import { WorkflowAction } from './tasks/models/workflow-action-object.model'; +import { LocaleInterceptor } from './locale/locale.interceptor'; import { ItemTemplateDataService } from './data/item-template-data.service'; import { TemplateItem } from './shared/template-item.model'; import { Registration } from './shared/registration.model'; @@ -283,6 +284,12 @@ const PROVIDERS = [ useClass: AuthInterceptor, multi: true }, + // register LocaleInterceptor as HttpInterceptor + { + provide: HTTP_INTERCEPTORS, + useClass: LocaleInterceptor, + multi: true + }, NotificationsService, FilteredDiscoveryPageResponseParsingService, { provide: NativeWindowService, useFactory: NativeWindowFactory } diff --git a/src/app/core/locale/locale.interceptor.spec.ts b/src/app/core/locale/locale.interceptor.spec.ts new file mode 100644 index 0000000000..4f45c4765a --- /dev/null +++ b/src/app/core/locale/locale.interceptor.spec.ts @@ -0,0 +1,74 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule, HttpTestingController, } from '@angular/common/http/testing'; +import { HTTP_INTERCEPTORS } from '@angular/common/http'; + +import { DSpaceRESTv2Service } from '../dspace-rest-v2/dspace-rest-v2.service'; +import { RestRequestMethod } from '../data/rest-request-method'; +import { LocaleService } from './locale.service'; +import { LocaleInterceptor } from './locale.interceptor'; +import { of } from 'rxjs'; + +describe(`LocaleInterceptor`, () => { + let service: DSpaceRESTv2Service; + let httpMock: HttpTestingController; + let localeService: any; + + const languageList = ['en;q=1', 'it;q=0.9', 'de;q=0.8', 'fr;q=0.7']; + + const mockLocaleService = jasmine.createSpyObj('LocaleService', { + getCurrentLanguageCode: jasmine.createSpy('getCurrentLanguageCode'), + getLanguageCodeList: of(languageList) + }) + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + DSpaceRESTv2Service, + { + provide: HTTP_INTERCEPTORS, + useClass: LocaleInterceptor, + multi: true, + }, + {provide: LocaleService, useValue: mockLocaleService}, + ], + }); + + service = TestBed.get(DSpaceRESTv2Service); + httpMock = TestBed.get(HttpTestingController); + localeService = TestBed.get(LocaleService); + + localeService.getCurrentLanguageCode.and.returnValue('en') + }); + + describe('', () => { + + it('should add an Accept-Language header when we’re sending an HTTP POST request', () => { + service.request(RestRequestMethod.POST, 'server/api/submission/workspaceitems', 'test').subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpMock.expectOne(`server/api/submission/workspaceitems`); + + expect(httpRequest.request.headers.has('Accept-Language')); + const lang = httpRequest.request.headers.get('Accept-Language'); + expect(lang).toBeDefined(); + expect(lang).toBe(languageList.toString()); + }); + + it('should add an Accept-Language header when we’re sending an HTTP GET request', () => { + service.request(RestRequestMethod.GET, 'server/api/submission/workspaceitems/123').subscribe((response) => { + expect(response).toBeTruthy(); + }); + + const httpRequest = httpMock.expectOne(`server/api/submission/workspaceitems/123`); + + expect(httpRequest.request.headers.has('Accept-Language')); + const lang = httpRequest.request.headers.get('Accept-Language'); + expect(lang).toBeDefined(); + expect(lang).toBe(languageList.toString()); + }); + + }); + +}); diff --git a/src/app/core/locale/locale.interceptor.ts b/src/app/core/locale/locale.interceptor.ts new file mode 100644 index 0000000000..9327db5d38 --- /dev/null +++ b/src/app/core/locale/locale.interceptor.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; + +import { Observable } from 'rxjs'; + +import { LocaleService } from './locale.service'; +import { mergeMap, scan } from 'rxjs/operators'; + +@Injectable() +export class LocaleInterceptor implements HttpInterceptor { + + constructor(private localeService: LocaleService) { + } + + /** + * Intercept method + * @param req + * @param next + */ + intercept(req: HttpRequest, next: HttpHandler): Observable> { + let newReq: HttpRequest; + return this.localeService.getLanguageCodeList() + .pipe( + scan((acc: any, value: any) => [...acc, ...value], []), + mergeMap((languages) => { + // Clone the request to add the new header. + newReq = req.clone({ + headers: req.headers + .set('Accept-Language', languages.toString()) + }); + // Pass on the new request instead of the original request. + return next.handle(newReq); + })) + } +} diff --git a/src/app/core/locale/locale.service.spec.ts b/src/app/core/locale/locale.service.spec.ts new file mode 100644 index 0000000000..67a4d61bf3 --- /dev/null +++ b/src/app/core/locale/locale.service.spec.ts @@ -0,0 +1,125 @@ +import { async, TestBed } from '@angular/core/testing'; + +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { CookieService } from '../services/cookie.service'; +import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock'; +import { TranslateLoaderMock } from '../../shared/mocks/translate-loader.mock'; +import { LANG_COOKIE, LocaleService, LANG_ORIGIN } from './locale.service'; +import { AuthService } from '../auth/auth.service'; +import { AuthServiceMock } from 'src/app/shared/mocks/auth.service.mock'; +import { NativeWindowRef } from '../services/window.service'; + +describe('LocaleService test suite', () => { + let service: LocaleService; + let serviceAsAny: any; + let cookieService: CookieService; + let translateService: TranslateService; + let authService: AuthService; + let window; + let spyOnGet; + let spyOnSet; + + const langList = ['en', 'it', 'de']; + + beforeEach(async(() => { + return TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useClass: TranslateLoaderMock + } + }), + ], + providers: [ + { provide: CookieService, useValue: new CookieServiceMock() }, + { provide: AuthService, userValue: AuthServiceMock } + ] + }); + })); + + beforeEach(() => { + cookieService = TestBed.get(CookieService); + translateService = TestBed.get(TranslateService); + authService = TestBed.get(TranslateService); + window = new NativeWindowRef(); + service = new LocaleService(window, cookieService, translateService, authService); + serviceAsAny = service; + spyOnGet = spyOn(cookieService, 'get'); + spyOnSet = spyOn(cookieService, 'set'); + }); + + describe('getCurrentLanguageCode', () => { + it('should return language saved on cookie', () => { + spyOnGet.and.returnValue('de'); + expect(service.getCurrentLanguageCode()).toBe('de'); + }); + + describe('', () => { + beforeEach(() => { + spyOn(translateService, 'getLangs').and.returnValue(langList); + }); + + it('should return language from browser setting', () => { + spyOn(translateService, 'getBrowserLang').and.returnValue('it'); + expect(service.getCurrentLanguageCode()).toBe('it'); + }); + + it('should return default language from config', () => { + spyOn(translateService, 'getBrowserLang').and.returnValue('fr'); + expect(service.getCurrentLanguageCode()).toBe('en'); + }); + }); + }); + + describe('getLanguageCodeFromCookie', () => { + it('should return language from cookie', () => { + spyOnGet.and.returnValue('de'); + expect(service.getLanguageCodeFromCookie()).toBe('de'); + }); + + }); + + describe('saveLanguageCodeToCookie', () => { + it('should save language to cookie', () => { + service.saveLanguageCodeToCookie('en'); + expect(spyOnSet).toHaveBeenCalledWith(LANG_COOKIE, 'en'); + }); + }); + + describe('setCurrentLanguageCode', () => { + beforeEach(() => { + spyOn(service, 'saveLanguageCodeToCookie'); + spyOn(translateService, 'use'); + }); + + it('should set the given language', () => { + service.setCurrentLanguageCode('it'); + expect(translateService.use).toHaveBeenCalledWith( 'it'); + expect(service.saveLanguageCodeToCookie).toHaveBeenCalledWith('it'); + }); + + it('should set the current language', () => { + spyOn(service, 'getCurrentLanguageCode').and.returnValue('es'); + service.setCurrentLanguageCode(); + expect(translateService.use).toHaveBeenCalledWith( 'es'); + expect(service.saveLanguageCodeToCookie).toHaveBeenCalledWith('es'); + }); + }); + + describe('', () => { + it('should set quality to current language list', () => { + const langListWithQuality = ['en;q=1', 'it;q=0.9', 'de;q=0.8']; + spyOn(service, 'setQuality').and.returnValue(langListWithQuality); + service.setQuality(langList, LANG_ORIGIN.BROWSER, false); + expect(service.setQuality).toHaveBeenCalledWith(langList, LANG_ORIGIN.BROWSER, false); + }); + + it('should return the list of language with quality factor', () => { + spyOn(service, 'getLanguageCodeList'); + service.getLanguageCodeList(); + expect(service.getLanguageCodeList).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/app/core/locale/locale.service.ts b/src/app/core/locale/locale.service.ts new file mode 100644 index 0000000000..b7f9314a33 --- /dev/null +++ b/src/app/core/locale/locale.service.ts @@ -0,0 +1,192 @@ +import { Injectable, Inject } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { isEmpty, isNotEmpty } from '../../shared/empty.util'; +import { CookieService } from '../services/cookie.service'; +import { environment } from '../../../environments/environment'; +import { AuthService } from '../auth/auth.service'; +import { Observable, of as observableOf, combineLatest } from 'rxjs'; +import { map, take, flatMap } from 'rxjs/operators'; +import { NativeWindowService, NativeWindowRef } from '../services/window.service'; + +export const LANG_COOKIE = 'language_cookie'; + +/** + * This enum defines the possible origin of the languages + */ +export enum LANG_ORIGIN { + UI, + EPERSON, + BROWSER +}; + +/** + * Service to provide localization handler + */ +@Injectable({ + providedIn: 'root' +}) +export class LocaleService { + + /** + * Eperson language metadata + */ + EPERSON_LANG_METADATA = 'eperson.language'; + + constructor( + @Inject(NativeWindowService) protected _window: NativeWindowRef, + protected cookie: CookieService, + protected translate: TranslateService, + protected authService: AuthService) { + } + + /** + * Get the language currently used + * + * @returns {string} The language code + */ + getCurrentLanguageCode(): string { + // Attempt to get the language from a cookie + let lang = this.getLanguageCodeFromCookie(); + if (isEmpty(lang)) { + // Cookie not found + // Attempt to get the browser language from the user + if (this.translate.getLangs().includes(this.translate.getBrowserLang())) { + lang = this.translate.getBrowserLang(); + } else { + lang = environment.defaultLanguage; + } + } + return lang; + } + + /** + * Get the languages list of the user in Accept-Language format + * + * @returns {Observable} + */ + getLanguageCodeList(): Observable { + const obs$ = combineLatest([ + this.authService.isAuthenticated(), + this.authService.isAuthenticationLoaded() + ]); + + return obs$.pipe( + take(1), + flatMap(([isAuthenticated, isLoaded]) => { + let epersonLang$: Observable = observableOf([]); + if (isAuthenticated && isLoaded) { + epersonLang$ = this.authService.getAuthenticatedUserFromStore().pipe( + take(1), + map((eperson) => { + const languages: string[] = []; + const ePersonLang = eperson.firstMetadataValue(this.EPERSON_LANG_METADATA); + if (ePersonLang) { + languages.push(...this.setQuality( + [ePersonLang], + LANG_ORIGIN.EPERSON, + !isEmpty(this.translate.currentLang))); + } + return languages; + }) + ); + } + return epersonLang$.pipe( + map((epersonLang: string[]) => { + const languages: string[] = []; + if (this.translate.currentLang) { + languages.push(...this.setQuality( + [this.translate.currentLang], + LANG_ORIGIN.UI, + false)); + } + if (isNotEmpty(epersonLang)) { + languages.push(...epersonLang); + } + if (navigator.languages) { + languages.push(...this.setQuality( + Object.assign([], navigator.languages), + LANG_ORIGIN.BROWSER, + !isEmpty(this.translate.currentLang)) + ); + } + return languages; + }) + ) + }) + ); + } + + /** + * Retrieve the language from a cookie + */ + getLanguageCodeFromCookie(): string { + return this.cookie.get(LANG_COOKIE); + } + + /** + * Set the language currently used + * + * @param lang + * The language to save + */ + saveLanguageCodeToCookie(lang: string): void { + this.cookie.set(LANG_COOKIE, lang); + } + + /** + * Set the language currently used + * + * @param lang + * The language to set, if it's not provided retrieve default one + */ + setCurrentLanguageCode(lang?: string): void { + if (isEmpty(lang)) { + lang = this.getCurrentLanguageCode() + } + this.translate.use(lang); + this.saveLanguageCodeToCookie(lang); + } + + /** + * Set the quality factor for all element of input array. + * Returns a new array that contains the languages list with the quality value. + * The quality factor indicate the relative degree of preference for the language + * @param languages the languages list + * @param origin origin of language list (UI, EPERSON, BROWSER) + * @param hasOther true if contains other language, false otherwise + */ + setQuality(languages: string[], origin: LANG_ORIGIN, hasOther: boolean): string[] { + const langWithPrior = []; + let idx = 0; + const v = languages.length > 10 ? languages.length : 10; + let divisor: number; + switch (origin) { + case LANG_ORIGIN.EPERSON: + divisor = 2; break; + case LANG_ORIGIN.BROWSER: + divisor = (hasOther ? 10 : 1); break; + default: + divisor = 1; + } + languages.forEach( (lang) => { + let value = lang + ';q='; + let quality = (v - idx++) / v; + quality = ((languages.length > 10) ? quality.toFixed(2) : quality) as number; + value += quality / divisor; + langWithPrior.push(value); + }); + return langWithPrior; + } + + /** + * Refresh route navigated + */ + public refreshAfterChangeLanguage() { + // Hard redirect to the reload page with a unique number behind it + // so that all state is definitely lost + this._window.nativeWindow.location.href = `/reload/${new Date().getTime()}`; + } + +} diff --git a/src/app/core/locale/server-locale.service.ts b/src/app/core/locale/server-locale.service.ts new file mode 100644 index 0000000000..b33338240f --- /dev/null +++ b/src/app/core/locale/server-locale.service.ts @@ -0,0 +1,60 @@ +import { LocaleService, LANG_ORIGIN } from './locale.service'; +import { Injectable } from '@angular/core'; +import { Observable, combineLatest, of as observableOf } from 'rxjs'; +import { take, flatMap, map } from 'rxjs/operators'; +import { isNotEmpty, isEmpty } from 'src/app/shared/empty.util'; + +@Injectable() +export class ServerLocaleService extends LocaleService { + + /** + * Get the languages list of the user in Accept-Language format + * + * @returns {Observable} + */ + getLanguageCodeList(): Observable { + const obs$ = combineLatest([ + this.authService.isAuthenticated(), + this.authService.isAuthenticationLoaded() + ]); + + return obs$.pipe( + take(1), + flatMap(([isAuthenticated, isLoaded]) => { + let epersonLang$: Observable = observableOf([]); + if (isAuthenticated && isLoaded) { + epersonLang$ = this.authService.getAuthenticatedUserFromStore().pipe( + take(1), + map((eperson) => { + const languages: string[] = []; + const ePersonLang = eperson.firstMetadataValue(this.EPERSON_LANG_METADATA); + if (ePersonLang) { + languages.push(...this.setQuality( + [ePersonLang], + LANG_ORIGIN.EPERSON, + !isEmpty(this.translate.currentLang))); + } + return languages; + }) + ); + } + return epersonLang$.pipe( + map((epersonLang: string[]) => { + const languages: string[] = []; + if (this.translate.currentLang) { + languages.push(...this.setQuality( + [this.translate.currentLang], + LANG_ORIGIN.UI, + false)); + } + if (isNotEmpty(epersonLang)) { + languages.push(...epersonLang); + } + return languages; + }) + ) + }) + ); + } + +} diff --git a/src/app/shared/lang-switch/lang-switch.component.spec.ts b/src/app/shared/lang-switch/lang-switch.component.spec.ts index 4e24ba4d04..9b8ee2e343 100644 --- a/src/app/shared/lang-switch/lang-switch.component.spec.ts +++ b/src/app/shared/lang-switch/lang-switch.component.spec.ts @@ -1,13 +1,14 @@ -import { LangSwitchComponent } from './lang-switch.component'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { DebugElement, NO_ERRORS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; + +import { Observable, of } from 'rxjs'; import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; + +import { LangSwitchComponent } from './lang-switch.component'; import { LangConfig } from '../../../config/lang-config.interface'; -import { Observable, of } from 'rxjs'; -import { By } from '@angular/platform-browser'; -import { CookieServiceMock } from '../mocks/cookie.service.mock'; -import { CookieService } from '../../core/services/cookie.service'; +import { LocaleService } from '../../core/locale/locale.service'; // This test is completely independent from any message catalogs or keys in the codebase // The translation module is instantiated with these bogus messages that we aren't using anyway. @@ -31,13 +32,16 @@ class CustomLoader implements TranslateLoader { /* tslint:enable:quotemark */ /* tslint:enable:object-literal-key-quotes */ -let cookie: CookieService; +let localService: any; describe('LangSwitchComponent', () => { - beforeEach(() => { - cookie = Object.assign(new CookieServiceMock()); - }); + function getMockLocaleService(): LocaleService { + return jasmine.createSpyObj('LocaleService', { + setCurrentLanguageCode: jasmine.createSpy('setCurrentLanguageCode'), + refreshAfterChangeLanguage: jasmine.createSpy('refreshAfterChangeLanguage') + }) + } describe('with English and Deutsch activated, English as default', () => { let component: LangSwitchComponent; @@ -72,7 +76,7 @@ describe('LangSwitchComponent', () => { schemas: [NO_ERRORS_SCHEMA], providers: [ TranslateService, - { provide: CookieService, useValue: cookie } + { provide: LocaleService, useValue: getMockLocaleService() }, ] }).compileComponents() .then(() => { @@ -82,6 +86,7 @@ describe('LangSwitchComponent', () => { translate.use('en'); http = TestBed.get(HttpTestingController); fixture = TestBed.createComponent(LangSwitchComponent); + localService = TestBed.get(LocaleService); component = fixture.componentInstance; de = fixture.debugElement; langSwitchElement = de.nativeElement; @@ -110,19 +115,16 @@ describe('LangSwitchComponent', () => { describe('when selecting a language', () => { beforeEach(() => { spyOn(translate, 'use'); - spyOn(cookie, 'set'); const langItem = fixture.debugElement.query(By.css('.dropdown-item')).nativeElement; langItem.click(); fixture.detectChanges(); }); - it('should translate the app', () => { - expect(translate.use).toHaveBeenCalled(); + it('should translate the app and set the client\'s language cookie', () => { + expect(localService.setCurrentLanguageCode).toHaveBeenCalled(); + expect(localService.refreshAfterChangeLanguage).toHaveBeenCalled(); }); - it('should set the client\'s language cookie', () => { - expect(cookie.set).toHaveBeenCalled(); - }); }); }); @@ -160,7 +162,7 @@ describe('LangSwitchComponent', () => { schemas: [NO_ERRORS_SCHEMA], providers: [ TranslateService, - { provide: CookieService, useValue: cookie } + { provide: LocaleService, useValue: getMockLocaleService() } ] }).compileComponents(); translate = TestBed.get(TranslateService); diff --git a/src/app/shared/lang-switch/lang-switch.component.ts b/src/app/shared/lang-switch/lang-switch.component.ts index 433d9038d7..e28b1d4bb7 100644 --- a/src/app/shared/lang-switch/lang-switch.component.ts +++ b/src/app/shared/lang-switch/lang-switch.component.ts @@ -1,9 +1,10 @@ -import {Component, Inject, OnInit} from '@angular/core'; -import {TranslateService} from '@ngx-translate/core'; -import {LangConfig} from '../../../config/lang-config.interface'; -import { LANG_COOKIE } from '../../app.component'; -import { CookieService } from '../../core/services/cookie.service'; +import { Component, Inject, OnInit } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { LangConfig } from '../../../config/lang-config.interface'; import { environment } from '../../../environments/environment'; +import { LocaleService } from '../../core/locale/locale.service'; @Component({ selector: 'ds-lang-switch', @@ -25,7 +26,7 @@ export class LangSwitchComponent implements OnInit { constructor( public translate: TranslateService, - public cookie: CookieService + private localeService: LocaleService ) { } @@ -53,8 +54,8 @@ export class LangSwitchComponent implements OnInit { * @param lang The language to switch to */ useLang(lang: string) { - this.translate.use(lang); - this.cookie.set(LANG_COOKIE, lang); + this.localeService.setCurrentLanguageCode(lang); + this.localeService.refreshAfterChangeLanguage(); } } diff --git a/src/modules/app/browser-app.module.ts b/src/modules/app/browser-app.module.ts index f43074e033..73a49b0211 100644 --- a/src/modules/app/browser-app.module.ts +++ b/src/modules/app/browser-app.module.ts @@ -44,6 +44,7 @@ export function getRequest(transferState: TransferState): any { RouterModule.forRoot([], { // enableTracing: true, useHash: false, + scrollPositionRestoration: 'enabled', preloadingStrategy: IdlePreload }), diff --git a/src/modules/app/server-app.module.ts b/src/modules/app/server-app.module.ts index d46a013b3b..0ba09182cc 100644 --- a/src/modules/app/server-app.module.ts +++ b/src/modules/app/server-app.module.ts @@ -25,6 +25,8 @@ import { ServerSubmissionService } from '../../app/submission/server-submission. import { Angulartics2DSpace } from '../../app/statistics/angulartics/dspace-provider'; import { Angulartics2RouterlessModule } from 'angulartics2/routerlessmodule'; import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader'; +import { ServerLocaleService } from 'src/app/core/locale/server-locale.service'; +import { LocaleService } from 'src/app/core/locale/locale.service'; import { HTTP_INTERCEPTORS } from '@angular/common/http'; import { ForwardClientIpInterceptor } from '../../app/core/forward-client-ip/forward-client-ip.interceptor'; @@ -76,6 +78,10 @@ export function createTranslateLoader() { provide: SubmissionService, useClass: ServerSubmissionService }, + { + provide: LocaleService, + useClass: ServerLocaleService + }, // register ForwardClientIpInterceptor as HttpInterceptor { provide: HTTP_INTERCEPTORS,