[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
This commit is contained in:
Denys Vuika 2018-12-20 16:41:56 +00:00 committed by Eugenio Romano
parent d6df5bc862
commit b5f9036545
9 changed files with 174 additions and 142 deletions

View File

@ -249,6 +249,9 @@ The table below illustrates how the selection is made:
| X | fr | jp | en | fr | | X | fr | jp | en | fr |
| it | fr | jp | en | it | | 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 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. will be used from that point on, regardless of the `app.config.json` and browser settings.

View File

@ -46,8 +46,8 @@
tabindex="1"> tabindex="1">
</mat-form-field> </mat-form-field>
<span class="adf-login-validation" for="username" *ngIf="formError.username"> <span class="adf-login-validation" for="username" *ngIf="formError['username']">
<span id="username-error" class="adf-login-error" data-automation-id="username-error">{{formError.username | translate }}</span> <span id="username-error" class="adf-login-error" data-automation-id="username-error">{{formError['username'] | translate }}</span>
</span> </span>
</div> </div>
@ -55,7 +55,7 @@
<div class="adf-login__field"> <div class="adf-login__field">
<mat-form-field class="adf-full-width" floatPlaceholder="never" color="primary"> <mat-form-field class="adf-full-width" floatPlaceholder="never" color="primary">
<input matInput placeholder="{{'LOGIN.LABEL.PASSWORD' | translate }}" <input matInput placeholder="{{'LOGIN.LABEL.PASSWORD' | translate }}"
type="password" [type]="isPasswordShow ? 'text' : 'password'"
[formControl]="form.controls['password']" [formControl]="form.controls['password']"
id="password" id="password"
data-automation-id="password" data-automation-id="password"
@ -69,9 +69,9 @@
visibility_off visibility_off
</mat-icon> </mat-icon>
</mat-form-field> </mat-form-field>
<span class="adf-login-validation" for="password" *ngIf="formError.password"> <span class="adf-login-validation" for="password" *ngIf="formError['password']">
<span id="password-required" class="adf-login-error" <span id="password-required" class="adf-login-error"
data-automation-id="password-required">{{formError.password | translate }}</span> data-automation-id="password-required">{{formError['password'] | translate }}</span>
</span> </span>
</div> </div>

View File

@ -15,7 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { Component, ElementRef, EventEmitter, import { Component, EventEmitter,
Input, OnInit, Output, TemplateRef, ViewEncapsulation Input, OnInit, Output, TemplateRef, ViewEncapsulation
} from '@angular/core'; } from '@angular/core';
import { AbstractControl, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { AbstractControl, FormBuilder, FormGroup, Validators } from '@angular/forms';
@ -42,6 +42,11 @@ enum LoginSteps {
Welcome = 2 Welcome = 2
} }
interface ValidationMessage {
value: string;
params?: any;
}
@Component({ @Component({
selector: 'adf-login', selector: 'adf-login',
templateUrl: './login.component.html', templateUrl: './login.component.html',
@ -134,7 +139,7 @@ export class LoginComponent implements OnInit {
headerTemplate: TemplateRef<any>; headerTemplate: TemplateRef<any>;
data: any; data: any;
private _message: { [id: string]: { [id: string]: string } }; private _message: { [id: string]: { [id: string]: ValidationMessage } };
/** /**
* Constructor * Constructor
@ -147,7 +152,6 @@ export class LoginComponent implements OnInit {
private authService: AuthenticationService, private authService: AuthenticationService,
private translateService: TranslationService, private translateService: TranslationService,
private logService: LogService, private logService: LogService,
private elementRef: ElementRef,
private router: Router, private router: Router,
private appConfig: AppConfigService, private appConfig: AppConfigService,
private userPreferences: UserPreferencesService, private userPreferences: UserPreferencesService,
@ -220,8 +224,11 @@ export class LoginComponent implements OnInit {
if (hasError) { if (hasError) {
for (let key in this.form.controls[field].errors) { for (let key in this.form.controls[field].errors) {
if (key) { if (key) {
this.formError[field] += const message = this._message[field][key];
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) => { (err: any) => {
this.actualLoginStep = LoginSteps.Landing; this.actualLoginStep = LoginSteps.Landing;
this.displayErrorMessage(err); this.displayErrorMessage(err);
this.enableError(); this.isError = true;
this.error.emit(new LoginErrorEvent(err)); this.error.emit(new LoginErrorEvent(err));
}, },
() => this.logService.info('Login done') () => this.logService.info('Login done')
@ -310,13 +317,10 @@ export class LoginComponent implements OnInit {
msg: string, msg: string,
params?: any params?: any
) { ) {
if (params) { this._message[field][ruleId] = {
this.translateService.get(msg, params).subscribe((res: string) => { value: msg,
this._message[field][ruleId] = res; params
}); };
} else {
this._message[field][ruleId] = msg;
}
} }
/** /**
@ -324,10 +328,6 @@ export class LoginComponent implements OnInit {
*/ */
toggleShowPassword() { toggleShowPassword() {
this.isPasswordShow = !this.isPasswordShow; 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() { private initFormFieldsMessagesDefault() {
this._message = { this._message = {
username: { 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: { 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() { private initFormFieldsDefault() {
@ -400,13 +407,6 @@ export class LoginComponent implements OnInit {
this.initFormError(); this.initFormError();
} }
/**
* Enable the error flag
*/
private enableError() {
this.isError = true;
}
private hasCustomFieldsValidation(): boolean { private hasCustomFieldsValidation(): boolean {
return this.fieldsValidation !== undefined; return this.fieldsValidation !== undefined;
} }

View File

@ -33,10 +33,15 @@ export class TranslateLoaderService implements TranslateLoader {
private suffix: string = '.json'; private suffix: string = '.json';
private providers: ComponentTranslationModel[] = []; private providers: ComponentTranslationModel[] = [];
private queue: string [][] = []; private queue: string [][] = [];
private defaultLang: string = 'en';
constructor(private http: HttpClient) { constructor(private http: HttpClient) {
} }
setDefaultLang(value: string) {
this.defaultLang = value || 'en';
}
registerProvider(name: string, path: string) { registerProvider(name: string, path: string) {
let registered = this.providers.find((provider) => provider.name === name); let registered = this.providers.find((provider) => provider.name === name);
if (registered) { if (registered) {
@ -50,6 +55,29 @@ export class TranslateLoaderService implements TranslateLoader {
return this.providers.find((x) => x.name === name) ? true : false; return this.providers.find((x) => x.name === name) ? true : false;
} }
fetchLanguageFile(lang: string, component: ComponentTranslationModel, fallbackUrl?: string): Observable<void> {
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<Observable<any>> { getComponentToFetch(lang: string): Array<Observable<any>> {
const observableBatch = []; const observableBatch = [];
if (!this.queue[lang]) { if (!this.queue[lang]) {
@ -59,16 +87,8 @@ export class TranslateLoaderService implements TranslateLoader {
if (!this.isComponentInQueue(lang, component.name)) { if (!this.isComponentInQueue(lang, component.name)) {
this.queue[lang].push(component.name); this.queue[lang].push(component.name);
const translationUrl = `${component.path}/${this.prefix}/${lang}${this.suffix}?v=${Date.now()}`;
observableBatch.push( observableBatch.push(
this.http.get(translationUrl).pipe( this.fetchLanguageFile(lang, component)
map((res: Response) => {
component.json[lang] = res;
}),
retry(3),
catchError(() => throwError(`Failed to load ${translationUrl}`))
)
); );
} }
}); });

View File

@ -43,6 +43,7 @@ export class TranslationService {
this.defaultLang = 'en'; this.defaultLang = 'en';
translate.setDefaultLang(this.defaultLang); translate.setDefaultLang(this.defaultLang);
this.customLoader.setDefaultLang(this.defaultLang);
if (providers && providers.length > 0) { if (providers && providers.length > 0) {
for (let provider of providers) { for (let provider of providers) {

View File

@ -117,24 +117,24 @@ describe('UserPreferencesService', () => {
it('should return as default locale the app.config locate as first', () => { it('should return as default locale the app.config locate as first', () => {
appConfig.config.locale = 'fake-locate-config'; 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'); expect(preferences.getDefaultLocale()).toBe('fake-locate-config');
}); });
it('should return as default locale the browser locale as second', () => { 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'); expect(preferences.getDefaultLocale()).toBe('fake-locate-browser');
}); });
it('should return as default locale the component property as third ', () => { 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'); expect(preferences.getDefaultLocale()).toBe('en');
}); });
it('should return as locale the store locate', () => { it('should return as locale the store locate', () => {
preferences.locale = 'fake-store-locate'; preferences.locale = 'fake-store-locate';
appConfig.config.locale = 'fake-locate-config'; 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'); expect(preferences.locale).toBe('fake-store-locate');
}); });

View File

@ -183,7 +183,7 @@ export class UserPreferencesService {
* @returns Default locale language code * @returns Default locale language code
*/ */
public getDefaultLocale(): string { public getDefaultLocale(): string {
return this.appConfig.get<string>('locale') || this.translate.getBrowserLang() || 'en'; return this.appConfig.get<string>('locale') || this.translate.getBrowserCultureLang() || 'en';
} }
} }

View File

@ -31,16 +31,6 @@ describe('ErrorContentComponent', () => {
let element: HTMLElement; let element: HTMLElement;
let translateService: TranslationService; let translateService: TranslationService;
setupTestBed({
imports: [
CoreTestingModule
],
providers: [
{ provide: TranslationService, useClass: TranslationMock },
{ provide: ActivatedRoute, useValue: { params: of({id: '404'})}}
]
});
beforeEach(() => { beforeEach(() => {
fixture = TestBed.createComponent(ErrorContentComponent); fixture = TestBed.createComponent(ErrorContentComponent);
element = fixture.nativeElement; element = fixture.nativeElement;
@ -53,84 +43,109 @@ describe('ErrorContentComponent', () => {
TestBed.resetTestingModule(); TestBed.resetTestingModule();
}); });
it('should create error component', async(() => { describe(' with an undefined error', () => {
fixture.detectChanges();
expect(errorContentComponent).toBeTruthy();
}));
it('should render error code', async(() => { setupTestBed({
fixture.detectChanges(); imports: [
const errorContentElement = element.querySelector('.adf-error-content-code'); CoreTestingModule
expect(errorContentElement).not.toBeNull(); ],
expect(errorContentElement).toBeDefined(); providers: [
})); { provide: TranslationService, useClass: TranslationMock },
{ provide: ActivatedRoute, useValue: { params: of() } }
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(() => { it('should create error component', async(() => {
spyOn(translateService, 'instant').and.callFake((inputString) => { fixture.detectChanges();
return 'Secondary Button'; expect(errorContentComponent).toBeTruthy();
}); }));
fixture.detectChanges();
fixture.whenStable().then(() => { it('should render error code', async(() => {
const errorContentElement = element.querySelector('#adf-secondary-button'); fixture.detectChanges();
const errorContentElement = element.querySelector('.adf-error-content-code');
expect(errorContentElement).not.toBeNull(); expect(errorContentElement).not.toBeNull();
expect(errorContentElement).toBeDefined(); 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');
});
}));
});
}); });

View File

@ -46,7 +46,7 @@ export class ErrorContentComponent implements OnInit, AfterContentChecked {
/** Error code associated with this error. */ /** Error code associated with this error. */
@Input() @Input()
errorCode: string; errorCode: string = 'UNKNOWN';
hasSecondButton: boolean; hasSecondButton: boolean;
@ -58,15 +58,8 @@ export class ErrorContentComponent implements OnInit, AfterContentChecked {
ngOnInit() { ngOnInit() {
if (this.route) { if (this.route) {
this.route.params.forEach((params: Params) => { this.route.params.forEach((params: Params) => {
if (params['id'] && !this.errorCode) { if (params['id']) {
this.errorCode = 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';
}
} }
}); });
} }