From b5f903654543e6ad3f63daf0d0df14620bb2018f Mon Sep 17 00:00:00 2001 From: Denys Vuika Date: Thu, 20 Dec 2018 16:41:56 +0000 Subject: [PATCH] [ADF-3784] support for browser cultures (i18n) (#4066) * support language cultures * update docs * fix typo * fix tests * correctly replace fallback translations * login dialog fixes * fix error component * [denys-i18n-cultures] Fix error content unit tests --- docs/user-guide/internationalization.md | 3 + .../login/components/login.component.html | 10 +- lib/core/login/components/login.component.ts | 64 +++---- lib/core/services/translate-loader.service.ts | 38 +++- lib/core/services/translation.service.ts | 1 + .../services/user-preferences.service.spec.ts | 8 +- lib/core/services/user-preferences.service.ts | 2 +- .../error-content.component.spec.ts | 179 ++++++++++-------- .../error-content/error-content.component.ts | 11 +- 9 files changed, 174 insertions(+), 142 deletions(-) diff --git a/docs/user-guide/internationalization.md b/docs/user-guide/internationalization.md index d852963ec0..635cc98be7 100644 --- a/docs/user-guide/internationalization.md +++ b/docs/user-guide/internationalization.md @@ -249,6 +249,9 @@ The table below illustrates how the selection is made: | X | fr | jp | en | fr | | it | fr | jp | en | it | +The translation service probes the browser culture first, for example `en-GB`. +If the `en-GB.json` file does not exist, the service falls back to the language id: `en`. + Once the locale language is determined, it is saved to the user preferences and this saved value will be used from that point on, regardless of the `app.config.json` and browser settings. diff --git a/lib/core/login/components/login.component.html b/lib/core/login/components/login.component.html index 2fc2b3c7c2..71bb7023b8 100644 --- a/lib/core/login/components/login.component.html +++ b/lib/core/login/components/login.component.html @@ -46,8 +46,8 @@ tabindex="1"> - - + @@ -55,7 +55,7 @@ diff --git a/lib/core/login/components/login.component.ts b/lib/core/login/components/login.component.ts index 96e9dcc5c1..7462447f86 100644 --- a/lib/core/login/components/login.component.ts +++ b/lib/core/login/components/login.component.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Component, ElementRef, EventEmitter, +import { Component, EventEmitter, Input, OnInit, Output, TemplateRef, ViewEncapsulation } from '@angular/core'; import { AbstractControl, FormBuilder, FormGroup, Validators } from '@angular/forms'; @@ -42,6 +42,11 @@ enum LoginSteps { Welcome = 2 } +interface ValidationMessage { + value: string; + params?: any; +} + @Component({ selector: 'adf-login', templateUrl: './login.component.html', @@ -134,7 +139,7 @@ export class LoginComponent implements OnInit { headerTemplate: TemplateRef; data: any; - private _message: { [id: string]: { [id: string]: string } }; + private _message: { [id: string]: { [id: string]: ValidationMessage } }; /** * Constructor @@ -147,7 +152,6 @@ export class LoginComponent implements OnInit { private authService: AuthenticationService, private translateService: TranslationService, private logService: LogService, - private elementRef: ElementRef, private router: Router, private appConfig: AppConfigService, private userPreferences: UserPreferencesService, @@ -220,8 +224,11 @@ export class LoginComponent implements OnInit { if (hasError) { for (let key in this.form.controls[field].errors) { if (key) { - this.formError[field] += - this._message[field][key] + ''; + const message = this._message[field][key]; + if (message && message.value) { + const translated = this.translateService.instant(message.value, message.params); + this.formError[field] += translated; + } } } } @@ -256,7 +263,7 @@ export class LoginComponent implements OnInit { (err: any) => { this.actualLoginStep = LoginSteps.Landing; this.displayErrorMessage(err); - this.enableError(); + this.isError = true; this.error.emit(new LoginErrorEvent(err)); }, () => this.logService.info('Login done') @@ -310,13 +317,10 @@ export class LoginComponent implements OnInit { msg: string, params?: any ) { - if (params) { - this.translateService.get(msg, params).subscribe((res: string) => { - this._message[field][ruleId] = res; - }); - } else { - this._message[field][ruleId] = msg; - } + this._message[field][ruleId] = { + value: msg, + params + }; } /** @@ -324,10 +328,6 @@ export class LoginComponent implements OnInit { */ toggleShowPassword() { this.isPasswordShow = !this.isPasswordShow; - this.elementRef.nativeElement.querySelector('#password').type = this - .isPasswordShow - ? 'text' - : 'password'; } /** @@ -371,18 +371,25 @@ export class LoginComponent implements OnInit { private initFormFieldsMessagesDefault() { this._message = { username: { - required: 'LOGIN.MESSAGES.USERNAME-REQUIRED' + required: { + value: 'LOGIN.MESSAGES.USERNAME-REQUIRED' + }, + minLength: { + value: 'LOGIN.MESSAGES.USERNAME-MIN', + params: { + get minLength() { + return this.minLength; + } + } + } + }, password: { - required: 'LOGIN.MESSAGES.PASSWORD-REQUIRED' + required: { + value: 'LOGIN.MESSAGES.PASSWORD-REQUIRED' + } } }; - - this.translateService - .get('LOGIN.MESSAGES.USERNAME-MIN', { minLength: this.minLength }) - .subscribe((res: string) => { - this._message['username']['minlength'] = res; - }); } private initFormFieldsDefault() { @@ -400,13 +407,6 @@ export class LoginComponent implements OnInit { this.initFormError(); } - /** - * Enable the error flag - */ - private enableError() { - this.isError = true; - } - private hasCustomFieldsValidation(): boolean { return this.fieldsValidation !== undefined; } diff --git a/lib/core/services/translate-loader.service.ts b/lib/core/services/translate-loader.service.ts index 3843d2f26e..d6a2773792 100644 --- a/lib/core/services/translate-loader.service.ts +++ b/lib/core/services/translate-loader.service.ts @@ -33,10 +33,15 @@ export class TranslateLoaderService implements TranslateLoader { private suffix: string = '.json'; private providers: ComponentTranslationModel[] = []; private queue: string [][] = []; + private defaultLang: string = 'en'; constructor(private http: HttpClient) { } + setDefaultLang(value: string) { + this.defaultLang = value || 'en'; + } + registerProvider(name: string, path: string) { let registered = this.providers.find((provider) => provider.name === name); if (registered) { @@ -50,6 +55,29 @@ export class TranslateLoaderService implements TranslateLoader { return this.providers.find((x) => x.name === name) ? true : false; } + fetchLanguageFile(lang: string, component: ComponentTranslationModel, fallbackUrl?: string): Observable { + const translationUrl = fallbackUrl || `${component.path}/${this.prefix}/${lang}${this.suffix}?v=${Date.now()}`; + + return this.http.get(translationUrl).pipe( + map((res: Response) => { + component.json[lang] = res; + }), + retry(3), + catchError(() => { + if (!fallbackUrl && lang.includes('-')) { + const [langId] = lang.split('-'); + + if (langId && langId !== this.defaultLang) { + const url = `${component.path}/${this.prefix}/${langId}${this.suffix}?v=${Date.now()}`; + + return this.fetchLanguageFile(lang, component, url); + } + } + return throwError(`Failed to load ${translationUrl}`); + }) + ); + } + getComponentToFetch(lang: string): Array> { const observableBatch = []; if (!this.queue[lang]) { @@ -59,16 +87,8 @@ export class TranslateLoaderService implements TranslateLoader { if (!this.isComponentInQueue(lang, component.name)) { this.queue[lang].push(component.name); - const translationUrl = `${component.path}/${this.prefix}/${lang}${this.suffix}?v=${Date.now()}`; - observableBatch.push( - this.http.get(translationUrl).pipe( - map((res: Response) => { - component.json[lang] = res; - }), - retry(3), - catchError(() => throwError(`Failed to load ${translationUrl}`)) - ) + this.fetchLanguageFile(lang, component) ); } }); diff --git a/lib/core/services/translation.service.ts b/lib/core/services/translation.service.ts index 1fbc00c80e..2d15fe6dfe 100644 --- a/lib/core/services/translation.service.ts +++ b/lib/core/services/translation.service.ts @@ -43,6 +43,7 @@ export class TranslationService { this.defaultLang = 'en'; translate.setDefaultLang(this.defaultLang); + this.customLoader.setDefaultLang(this.defaultLang); if (providers && providers.length > 0) { for (let provider of providers) { diff --git a/lib/core/services/user-preferences.service.spec.ts b/lib/core/services/user-preferences.service.spec.ts index ec7a90d160..3f4a439cbb 100644 --- a/lib/core/services/user-preferences.service.spec.ts +++ b/lib/core/services/user-preferences.service.spec.ts @@ -117,24 +117,24 @@ describe('UserPreferencesService', () => { it('should return as default locale the app.config locate as first', () => { appConfig.config.locale = 'fake-locate-config'; - spyOn(translate, 'getBrowserLang').and.returnValue('fake-locate-browser'); + spyOn(translate, 'getBrowserCultureLang').and.returnValue('fake-locate-browser'); expect(preferences.getDefaultLocale()).toBe('fake-locate-config'); }); it('should return as default locale the browser locale as second', () => { - spyOn(translate, 'getBrowserLang').and.returnValue('fake-locate-browser'); + spyOn(translate, 'getBrowserCultureLang').and.returnValue('fake-locate-browser'); expect(preferences.getDefaultLocale()).toBe('fake-locate-browser'); }); it('should return as default locale the component property as third ', () => { - spyOn(translate, 'getBrowserLang').and.stub(); + spyOn(translate, 'getBrowserCultureLang').and.stub(); expect(preferences.getDefaultLocale()).toBe('en'); }); it('should return as locale the store locate', () => { preferences.locale = 'fake-store-locate'; appConfig.config.locale = 'fake-locate-config'; - spyOn(translate, 'getBrowserLang').and.returnValue('fake-locate-browser'); + spyOn(translate, 'getBrowserCultureLang').and.returnValue('fake-locate-browser'); expect(preferences.locale).toBe('fake-store-locate'); }); diff --git a/lib/core/services/user-preferences.service.ts b/lib/core/services/user-preferences.service.ts index 0cf7f2f5ac..7d2963ff9e 100644 --- a/lib/core/services/user-preferences.service.ts +++ b/lib/core/services/user-preferences.service.ts @@ -183,7 +183,7 @@ export class UserPreferencesService { * @returns Default locale language code */ public getDefaultLocale(): string { - return this.appConfig.get('locale') || this.translate.getBrowserLang() || 'en'; + return this.appConfig.get('locale') || this.translate.getBrowserCultureLang() || 'en'; } } diff --git a/lib/core/templates/error-content/error-content.component.spec.ts b/lib/core/templates/error-content/error-content.component.spec.ts index c3b0a4fe96..460fcd30c9 100644 --- a/lib/core/templates/error-content/error-content.component.spec.ts +++ b/lib/core/templates/error-content/error-content.component.spec.ts @@ -31,16 +31,6 @@ describe('ErrorContentComponent', () => { let element: HTMLElement; let translateService: TranslationService; - setupTestBed({ - imports: [ - CoreTestingModule - ], - providers: [ - { provide: TranslationService, useClass: TranslationMock }, - { provide: ActivatedRoute, useValue: { params: of({id: '404'})}} - ] - }); - beforeEach(() => { fixture = TestBed.createComponent(ErrorContentComponent); element = fixture.nativeElement; @@ -53,84 +43,109 @@ describe('ErrorContentComponent', () => { TestBed.resetTestingModule(); }); - it('should create error component', async(() => { - fixture.detectChanges(); - expect(errorContentComponent).toBeTruthy(); - })); + describe(' with an undefined error', () => { - it('should render error code', async(() => { - fixture.detectChanges(); - const errorContentElement = element.querySelector('.adf-error-content-code'); - expect(errorContentElement).not.toBeNull(); - expect(errorContentElement).toBeDefined(); - })); - - it('should render error title', async(() => { - fixture.detectChanges(); - const errorContentElement = element.querySelector('.adf-error-content-title'); - expect(errorContentElement).not.toBeNull(); - expect(errorContentElement).toBeDefined(); - })); - - it('should render error description', async(() => { - fixture.detectChanges(); - const errorContentElement = element.querySelector('.adf-error-content-description'); - expect(errorContentElement).not.toBeNull(); - expect(errorContentElement).toBeDefined(); - })); - - it('should render error description', async(() => { - fixture.detectChanges(); - const errorContentElement = element.querySelector('.adf-error-content-description'); - expect(errorContentElement).not.toBeNull(); - expect(errorContentElement).toBeDefined(); - })); - - it('should hide secondary button if this one has no value', async(() => { - spyOn(translateService, 'instant').and.callFake((inputString) => { - return ''; + setupTestBed({ + imports: [ + CoreTestingModule + ], + providers: [ + { provide: TranslationService, useClass: TranslationMock }, + { provide: ActivatedRoute, useValue: { params: of() } } + ] }); - fixture.detectChanges(); - fixture.whenStable().then(() => { - const errorContentElement = element.querySelector('.adf-error-content-description-link'); - expect(errorContentElement).toBeNull(); - }); - })); - it('should render secondary button with its value from the translate file', async(() => { - spyOn(translateService, 'instant').and.callFake((inputString) => { - return 'Secondary Button'; - }); - fixture.detectChanges(); - fixture.whenStable().then(() => { - const errorContentElement = element.querySelector('#adf-secondary-button'); + it('should create error component', async(() => { + fixture.detectChanges(); + expect(errorContentComponent).toBeTruthy(); + })); + + it('should render error code', async(() => { + fixture.detectChanges(); + const errorContentElement = element.querySelector('.adf-error-content-code'); expect(errorContentElement).not.toBeNull(); expect(errorContentElement).toBeDefined(); - expect(errorContentElement.textContent).toContain('ERROR_CONTENT.UNKNOWN.SECONDARY_BUTTON.TEXT'); + })); + it('should render error title', async(() => { + fixture.detectChanges(); + const errorContentElement = element.querySelector('.adf-error-content-title'); + expect(errorContentElement).not.toBeNull(); + expect(errorContentElement).toBeDefined(); + })); + + it('should render error description', async(() => { + fixture.detectChanges(); + const errorContentElement = element.querySelector('.adf-error-content-description'); + expect(errorContentElement).not.toBeNull(); + expect(errorContentElement).toBeDefined(); + })); + + it('should render error description', async(() => { + fixture.detectChanges(); + const errorContentElement = element.querySelector('.adf-error-content-description'); + expect(errorContentElement).not.toBeNull(); + expect(errorContentElement).toBeDefined(); + })); + + it('should hide secondary button if this one has no value', async(() => { + spyOn(translateService, 'instant').and.callFake((inputString) => { + return ''; + }); + fixture.detectChanges(); + fixture.whenStable().then(() => { + const errorContentElement = element.querySelector('.adf-error-content-description-link'); + expect(errorContentElement).toBeNull(); + }); + })); + + it('should render secondary button with its value from the translate file', async(() => { + spyOn(translateService, 'instant').and.callFake((inputString) => { + return 'Secondary Button'; + }); + fixture.detectChanges(); + fixture.whenStable().then(() => { + const errorContentElement = element.querySelector('#adf-secondary-button'); + expect(errorContentElement).not.toBeNull(); + expect(errorContentElement).toBeDefined(); + expect(errorContentElement.textContent).toContain('ERROR_CONTENT.UNKNOWN.SECONDARY_BUTTON.TEXT'); + + }); + })); + + it('should the default value of return button be /', async(() => { + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(errorContentComponent.returnButtonUrl).toBe('/'); + }); + })); + + it('should navigate to the default error UNKNOWN if it does not find the error', async(() => { + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(errorContentComponent.errorCode).toBe('UNKNOWN'); + }); + })); + }); + + describe(' with a specific error', () => { + + setupTestBed({ + imports: [ + CoreTestingModule + ], + providers: [ + { provide: TranslationService, useClass: TranslationMock }, + { provide: ActivatedRoute, useValue: { params: of({ id: '404' }) } } + ] }); - })); - - it('should the default value of return button be /', async(() => { - fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(errorContentComponent.returnButtonUrl).toBe('/'); - }); - })); - - it('should navigate to an error given by the route params', async(() => { - spyOn(translateService, 'get').and.returnValue(of('404')); - fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(errorContentComponent.errorCode).toBe('404'); - }); - })); - - it('should navigate to the default error UNKNOWN if it does not find the error', async(() => { - fixture.detectChanges(); - fixture.whenStable().then(() => { - expect(errorContentComponent.errorCode).toBe('UNKNOWN'); - }); - })); + it('should navigate to an error given by the route params', async(() => { + spyOn(translateService, 'get').and.returnValue(of('404')); + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(errorContentComponent.errorCode).toBe('404'); + }); + })); + }); }); diff --git a/lib/core/templates/error-content/error-content.component.ts b/lib/core/templates/error-content/error-content.component.ts index 13601a7eec..2595d1419c 100644 --- a/lib/core/templates/error-content/error-content.component.ts +++ b/lib/core/templates/error-content/error-content.component.ts @@ -46,7 +46,7 @@ export class ErrorContentComponent implements OnInit, AfterContentChecked { /** Error code associated with this error. */ @Input() - errorCode: string; + errorCode: string = 'UNKNOWN'; hasSecondButton: boolean; @@ -58,15 +58,8 @@ export class ErrorContentComponent implements OnInit, AfterContentChecked { ngOnInit() { if (this.route) { this.route.params.forEach((params: Params) => { - if (params['id'] && !this.errorCode) { + if (params['id']) { this.errorCode = params['id']; - let unknown = ''; - this.translateService.get('ERROR_CONTENT.' + this.errorCode + '.TITLE').subscribe((errorTranslation: string) => { - unknown = errorTranslation; - }); - if (unknown === 'ERROR_CONTENT.' + this.errorCode + '.TITLE') { - this.errorCode = 'UNKNOWN'; - } } }); }