diff --git a/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts b/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts index 6e80d0c149..6ee98313b1 100644 --- a/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts +++ b/packages/compiler-cli/test/compliance/r3_view_compiler_i18n_spec.ts @@ -389,7 +389,7 @@ describe('i18n support in the view compiler', () => { it('should correctly bind to context in nested template', () => { const input = `
-
+
`; @@ -518,7 +518,7 @@ describe('i18n support in the view compiler', () => { it('should correctly bind to context in nested template', () => { const input = `
-
+
`; @@ -1347,7 +1347,7 @@ describe('i18n support in the view compiler', () => { verify(input, output); }); - it('should be able to be child elements inside i18n block', () => { + it('should be able to act as child elements inside i18n block', () => { const input = `
Template content: {{ valueA | uppercase }} diff --git a/packages/core/src/render3/i18n.ts b/packages/core/src/render3/i18n.ts index f0d6142ffd..386af20d0a 100644 --- a/packages/core/src/render3/i18n.ts +++ b/packages/core/src/render3/i18n.ts @@ -504,7 +504,7 @@ function appendI18nNode(tNode: TNode, parentTNode: TNode, previousTNode: TNode | * @publicAPI */ export function i18nPostprocess( - message: string, replacements: {[key: string]: (string | string[])}): string { + message: string, replacements: {[key: string]: (string | string[])} = {}): string { // // Step 1: resolve all multi-value cases (like [�*1:1��#2:1�|�#4:1�|�5�]) // diff --git a/packages/core/test/i18n_integration_spec.ts b/packages/core/test/i18n_integration_spec.ts new file mode 100644 index 0000000000..0c13466735 --- /dev/null +++ b/packages/core/test/i18n_integration_spec.ts @@ -0,0 +1,480 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component, Directive, TemplateRef, ViewContainerRef} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {expect} from '@angular/platform-browser/testing/src/matchers'; +import {fixmeIvy, onlyInIvy, polyfillGoogGetMsg} from '@angular/private/testing'; + +@Directive({ + selector: '[tplRef]', +}) +class DirectiveWithTplRef { + constructor(public vcRef: ViewContainerRef, public tplRef: TemplateRef<{}>) {} + ngOnInit() { this.vcRef.createEmbeddedView(this.tplRef, {}); } +} + +@Component({selector: 'my-comp', template: ''}) +class MyComp { + name = 'John'; + items = ['1', '2', '3']; + visible = true; + age = 20; + count = 2; + otherLabel = 'other label'; +} + +const TRANSLATIONS: any = { + 'one': 'un', + 'two': 'deux', + 'more than two': 'plus que deux', + 'ten': 'dix', + 'twenty': 'vingt', + 'other': 'autres', + 'Hello': 'Bonjour', + 'Hello {$interpolation}': 'Bonjour {$interpolation}', + 'Bye': 'Au revoir', + 'Item {$interpolation}': 'Article {$interpolation}', + '\'Single quotes\' and "Double quotes"': '\'Guillemets simples\' et "Guillemets doubles"', + 'My logo': 'Mon logo', + '{$startTagSpan}My logo{$tagImg}{$closeTagSpan}': + '{$startTagSpan}Mon logo{$tagImg}{$closeTagSpan}', + '{$startTagNgTemplate} Hello {$closeTagNgTemplate}{$startTagNgContainer} Bye {$closeTagNgContainer}': + '{$startTagNgTemplate} Bonjour {$closeTagNgTemplate}{$startTagNgContainer} Au revoir {$closeTagNgContainer}', + '{$startTagNgTemplate}{$startTagSpan}Hello{$closeTagSpan}{$closeTagNgTemplate}{$startTagNgContainer}{$startTagSpan_1}Hello{$closeTagSpan}{$closeTagNgContainer}': + '{$startTagNgTemplate}{$startTagSpan}Bonjour{$closeTagSpan}{$closeTagNgTemplate}{$startTagNgContainer}{$startTagSpan_1}Bonjour{$closeTagSpan}{$closeTagNgContainer}', + '{VAR_SELECT, select, 10 {ten} 20 {twenty} other {other}}': + '{VAR_SELECT, select, 10 {dix} 20 {vingt} other {autres}}', + '{VAR_SELECT, select, 10 {10 - {$startBoldText}ten{$closeBoldText}} 20 {20 - {$startItalicText}twenty{$closeItalicText}} other {{$startTagDiv}{$startUnderlinedText}other{$closeUnderlinedText}{$closeTagDiv}}}': + '{VAR_SELECT, select, 10 {10 - {$startBoldText}dix{$closeBoldText}} 20 {20 - {$startItalicText}vingt{$closeItalicText}} other {{$startTagDiv}{$startUnderlinedText}autres{$closeUnderlinedText}{$closeTagDiv}}}', + '{VAR_SELECT_2, select, 10 {ten - {VAR_SELECT, select, 1 {one} 2 {two} other {more than two}}} 20 {twenty - {VAR_SELECT_1, select, 1 {one} 2 {two} other {more than two}}} other {other}}': + '{VAR_SELECT_2, select, 10 {dix - {VAR_SELECT, select, 1 {un} 2 {deux} other {plus que deux}}} 20 {vingt - {VAR_SELECT_1, select, 1 {un} 2 {deux} other {plus que deux}}} other {autres}}' +}; + +const getFixtureWithOverrides = (overrides = {}) => { + TestBed.overrideComponent(MyComp, {set: overrides}); + const fixture = TestBed.createComponent(MyComp); + fixture.detectChanges(); + return fixture; +}; + +onlyInIvy('Ivy i18n logic').describe('i18n', function() { + + beforeEach(() => { + polyfillGoogGetMsg(TRANSLATIONS); + TestBed.configureTestingModule({declarations: [MyComp, DirectiveWithTplRef]}); + }); + + describe('attributes', () => { + it('should translate static attributes', () => { + const title = 'Hello'; + const template = `
`; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element.title).toBe('Bonjour'); + }); + + it('should support interpolation', () => { + const title = 'Hello {{ name }}'; + const template = `
`; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element.title).toBe('Bonjour John'); + }); + + it('should support interpolation with custom interpolation config', () => { + const title = 'Hello {% name %}'; + const template = `
`; + const interpolation = ['{%', '%}'] as[string, string]; + const fixture = getFixtureWithOverrides({template, interpolation}); + + const element = fixture.nativeElement.firstChild; + expect(element.title).toBe('Bonjour John'); + }); + + fixmeIvy('FW-903: i18n attributes in nested templates throws at runtime') + .it('should correctly bind to context in nested template', () => { + const title = 'Item {{ id }}'; + const template = ` +
+
+
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + for (let i = 0; i < element.children.length; i++) { + const child = element.children[i]; + expect((child as any).innerHTML).toBe(`
`); + } + }); + + fixmeIvy('FW-904: i18n attributes placed on i18n root node don\'t work') + .it('should work correctly when placed on i18n root node', () => { + const title = 'Hello {{ name }}'; + const content = 'Hello'; + const template = ` +
${content}
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element.title).toBe('Bonjour John'); + expect(element).toHaveText('Bonjour'); + }); + + it('should add i18n attributes on self-closing tags', () => { + const title = 'Hello {{ name }}'; + const template = ``; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element.title).toBe('Bonjour John'); + }); + }); + + describe('nested nodes', () => { + it('should handle static content', () => { + const content = 'Hello'; + const template = `
${content}
`; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element).toHaveText('Bonjour'); + }); + + it('should support interpolation', () => { + const content = 'Hello {{ name }}'; + const template = `
${content}
`; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element).toHaveText('Bonjour John'); + }); + + it('should support interpolation with custom interpolation config', () => { + const content = 'Hello {% name %}'; + const template = `
${content}
`; + const interpolation = ['{%', '%}'] as[string, string]; + const fixture = getFixtureWithOverrides({template, interpolation}); + + const element = fixture.nativeElement.firstChild; + expect(element).toHaveText('Bonjour John'); + }); + + it('should properly escape quotes in content', () => { + const content = `'Single quotes' and "Double quotes"`; + const template = `
${content}
`; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element).toHaveText('\'Guillemets simples\' et "Guillemets doubles"'); + }); + + it('should correctly bind to context in nested template', () => { + const content = 'Item {{ id }}'; + const template = ` +
+
${content}
+
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + for (let i = 0; i < element.children.length; i++) { + const child = element.children[i]; + expect(child).toHaveText(`Article ${i + 1}`); + } + }); + + it('should handle i18n attributes inside i18n section', () => { + const title = 'Hello {{ name }}'; + const template = ` +
+
+
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + const content = `
`; + expect(element.innerHTML).toBe(content); + }); + + it('should handle i18n blocks in nested templates', () => { + const content = 'Hello {{ name }}'; + const template = ` +
+
${content}
+
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element.children[0]).toHaveText('Bonjour John'); + }); + + it('should ignore i18n attributes on self-closing tags', () => { + const template = ''; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + expect(element.innerHTML).toBe(template.replace(' i18n', '')); + }); + + it('should handle i18n attribute with directives', () => { + const content = 'Hello {{ name }}'; + const template = ` +
${content}
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element).toHaveText('Bonjour John'); + }); + }); + + describe('ng-container and ng-template support', () => { + it('should handle single translation message within ng-container', () => { + const content = 'Hello {{ name }}'; + const template = ` + ${content} + `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element).toHaveText('Bonjour John'); + }); + + it('should handle single translation message within ng-template', () => { + const content = 'Hello {{ name }}'; + const template = ` + ${content} + `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + expect(element).toHaveText('Bonjour John'); + }); + + it('should be able to act as child elements inside i18n block (plain text content)', () => { + const hello = 'Hello'; + const bye = 'Bye'; + const template = ` +
+ + ${hello} + + + ${bye} + +
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element.textContent.replace(/\s+/g, ' ').trim()).toBe('Bonjour Au revoir'); + }); + + fixmeIvy( + 'FW-910: Invalid placeholder structure generated when using with content that contains tags') + .it('should be able to act as child elements inside i18n block (text + tags)', () => { + const content = 'Hello'; + const template = ` +
+ + ${content} + + + ${content} + +
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + const spans = element.getElementsByTagName('span'); + for (let i = 0; i < spans.length; i++) { + const child = spans[i]; + expect((child as any).innerHTML).toBe('Bonjour'); + } + }); + + it('should handle self-closing tags as content', () => { + const label = 'My logo'; + const content = `${label}`; + const template = ` + + ${content} + + + ${content} + + `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + const spans = element.getElementsByTagName('span'); + for (let i = 0; i < spans.length; i++) { + const child = spans[i]; + expect(child).toHaveText('Mon logo'); + } + }); + }); + + describe('ICU logic', () => { + it('should handle single ICUs', () => { + const template = ` +
{age, select, 10 {ten} 20 {twenty} other {other}}
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + expect(element).toHaveText('vingt'); + }); + + it('should support ICUs generated outside of i18n blocks', () => { + const template = ` +
{age, select, 10 {ten} 20 {twenty} other {other}}
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + expect(element).toHaveText('vingt'); + }); + + it('should support interpolation', () => { + const template = ` +
{age, select, 10 {ten} other {{{ otherLabel }}}}
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + expect(element).toHaveText(fixture.componentInstance.otherLabel); + }); + + it('should support interpolation with custom interpolation config', () => { + const template = ` +
{age, select, 10 {ten} other {{% otherLabel %}}}
+ `; + const interpolation = ['{%', '%}'] as[string, string]; + const fixture = getFixtureWithOverrides({template, interpolation}); + + const element = fixture.nativeElement; + expect(element).toHaveText(fixture.componentInstance.otherLabel); + }); + + it('should handle ICUs with HTML tags inside', () => { + const template = ` +
+ {age, select, 10 {10 - ten} 20 {20 - twenty} other {
other
}} +
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + const italicTags = element.getElementsByTagName('i'); + expect(italicTags.length).toBe(1); + expect(italicTags[0].innerHTML).toBe('vingt'); + }); + + fixmeIvy('FW-905: Multiple ICUs in one i18n block are not processed') + .it('should handle multiple ICUs in one block', () => { + const template = ` +
+ {age, select, 10 {ten} 20 {twenty} other {other}} - + {count, select, 1 {one} 2 {two} other {more than two}} +
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element).toHaveText('vingt - deux'); + }); + + fixmeIvy('FW-906: Multiple ICUs wrapped in HTML tags in one i18n block throw an error') + .it('should handle multiple ICUs in one i18n block wrapped in HTML elements', () => { + const template = ` +
+ + {age, select, 10 {ten} 20 {twenty} other {other}} + + + {count, select, 1 {one} 2 {two} other {more than two}} + +
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + const spans = element.getElementsByTagName('span'); + expect(spans.length).toBe(2); + expect(spans[0].innerHTML).toBe('vingt'); + expect(spans[1].innerHTML).toBe('deux'); + }); + + it('should handle ICUs inside a template in i18n block', () => { + const template = ` +
+ + {age, select, 10 {ten} 20 {twenty} other {other}} + +
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + const spans = element.getElementsByTagName('span'); + expect(spans.length).toBe(1); + expect(spans[0]).toHaveText('vingt'); + }); + + it('should handle nested icus', () => { + const template = ` +
+ {age, select, + 10 {ten - {count, select, 1 {one} 2 {two} other {more than two}}} + 20 {twenty - {count, select, 1 {one} 2 {two} other {more than two}}} + other {other}} +
+ `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement.firstChild; + expect(element).toHaveText('vingt - deux'); + }); + + fixmeIvy('FW-908: ICUs inside s throw an error at runtime') + .it('should handle ICUs inside ', () => { + const template = ` + + {age, select, 10 {ten} 20 {twenty} other {other}} + + `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + expect(element.innerHTML).toBe('vingt'); + }); + + fixmeIvy('FW-909: ICUs inside s throw errors at runtime') + .it('should handle ICUs inside ', () => { + const template = ` + + {age, select, 10 {ten} 20 {twenty} other {other}} + + `; + const fixture = getFixtureWithOverrides({template}); + + const element = fixture.nativeElement; + expect(element.innerHTML).toBe('vingt'); + }); + }); +}); \ No newline at end of file diff --git a/packages/private/testing/src/goog_get_msg.ts b/packages/private/testing/src/goog_get_msg.ts index 902339e955..2547839cd8 100644 --- a/packages/private/testing/src/goog_get_msg.ts +++ b/packages/private/testing/src/goog_get_msg.ts @@ -16,13 +16,12 @@ export function polyfillGoogGetMsg(translations: {[key: string]: string} = {}): void { const glob = (global as any); glob.goog = glob.goog || {}; - glob.goog.getMsg = - glob.goog.getMsg || function(input: string, placeholders: {[key: string]: string} = {}) { - if (typeof translations[input] !== 'undefined') { // to account for empty string - input = translations[input]; - } - return Object.keys(placeholders).length ? - input.replace(/\{\$(.*?)\}/g, (match, key) => placeholders[key] || '') : - input; - }; -} + glob.goog.getMsg = function(input: string, placeholders: {[key: string]: string} = {}) { + if (typeof translations[input] !== 'undefined') { // to account for empty string + input = translations[input]; + } + return Object.keys(placeholders).length ? + input.replace(/\{\$(.*?)\}/g, (match, key) => placeholders[key] || '') : + input; + }; +} \ No newline at end of file