[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 |
| 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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -183,7 +183,7 @@ export class UserPreferencesService {
* @returns Default locale language code
*/
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 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');
});
}));
});
});

View File

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