fix(compiler): support i18n attributes on `<ng-template>` tags (#35681)

Prior to this commit, i18n attributes defined on `<ng-template>` tags were not processed by the compiler. This commit adds the necessary logic to handle i18n attributes in the same way how these attrs are processed for regular elements.

PR Close #35681
This commit is contained in:
Andrew Kushnir 2020-02-25 22:59:34 -08:00 committed by atscott
parent 35c9f0dc2f
commit 40da51f641
3 changed files with 191 additions and 46 deletions

View File

@ -331,6 +331,78 @@ describe('i18n support in the template compiler', () => {
verify(input, output);
});
it('should support i18n attributes on explicit <ng-template> elements', () => {
const input = `
<ng-template i18n-title title="Hello"></ng-template>
`;
// TODO (FW-1942): update the code to avoid adding `title` attribute in plain form
// into the `consts` array on Component def.
const output = String.raw `
var $I18N_0$;
if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) {
const $MSG_EXTERNAL_6616505470450179563$$APP_SPEC_TS_1$ = goog.getMsg("Hello");
$I18N_0$ = $MSG_EXTERNAL_6616505470450179563$$APP_SPEC_TS_1$;
}
else {
$I18N_0$ = $localize \`Hello\`;
}
const $_c2$ = ["title", $I18N_0$];
consts: [["title", "Hello"]],
template: function MyComponent_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵtemplate(0, MyComponent_ng_template_0_Template, 0, 0, "ng-template", 0);
$r3$.ɵɵi18nAttributes(1, $_c2$);
}
}
`;
verify(input, output);
});
it('should support i18n attributes on explicit <ng-template> with structural directives',
() => {
const input = `
<ng-template *ngIf="visible" i18n-title title="Hello">Test</ng-template>
`;
// TODO (FW-1942): update the code to avoid adding `title` attribute in plain form
// into the `consts` array on Component def.
const output = String.raw `
var $I18N_0$;
if (typeof ngI18nClosureMode !== "undefined" && ngI18nClosureMode) {
const $MSG_EXTERNAL_6616505470450179563$$APP_SPEC_TS_1$ = goog.getMsg("Hello");
$I18N_0$ = $MSG_EXTERNAL_6616505470450179563$$APP_SPEC_TS_1$;
}
else {
$I18N_0$ = $localize \`Hello\`;
}
const $_c2$ = ["title", $I18N_0$];
function MyComponent_0_ng_template_0_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵtext(0, "Test");
}
}
function MyComponent_0_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵtemplate(0, MyComponent_0_ng_template_0_Template, 1, 0, "ng-template", 1);
$r3$.ɵɵi18nAttributes(1, $_c2$);
}
}
consts: [[${AttributeMarker.Template}, "ngIf"], ["title", "Hello"]],
template: function MyComponent_Template(rf, ctx) {
if (rf & 1) {
$r3$.ɵɵtemplate(0, MyComponent_0_Template, 2, 0, undefined, 0);
}
if (rf & 2) {
$r3$.ɵɵproperty("ngIf", ctx.visible);
}
}
`;
verify(input, output);
});
it('should not create translations for empty attributes', () => {
const input = `
<div id="static" i18n-title="m|d" title></div>

View File

@ -472,6 +472,46 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
this.i18n = null; // reset local i18n context
}
private i18nAttributesInstruction(
nodeIndex: number, attrs: (t.TextAttribute|t.BoundAttribute)[],
sourceSpan: ParseSourceSpan): void {
let hasBindings: boolean = false;
const i18nAttrArgs: o.Expression[] = [];
const bindings: ChainableBindingInstruction[] = [];
attrs.forEach(attr => {
const message = attr.i18n !as i18n.Message;
if (attr instanceof t.TextAttribute) {
i18nAttrArgs.push(o.literal(attr.name), this.i18nTranslate(message));
} else {
const converted = attr.value.visit(this._valueConverter);
this.allocateBindingSlots(converted);
if (converted instanceof Interpolation) {
const placeholders = assembleBoundTextPlaceholders(message);
const params = placeholdersToParams(placeholders);
i18nAttrArgs.push(o.literal(attr.name), this.i18nTranslate(message, params));
converted.expressions.forEach(expression => {
hasBindings = true;
bindings.push({
sourceSpan,
value: () => this.convertPropertyBinding(expression),
});
});
}
}
});
if (bindings.length > 0) {
this.updateInstructionChainWithAdvance(nodeIndex, R3.i18nExp, bindings);
}
if (i18nAttrArgs.length > 0) {
const index: o.Expression = o.literal(this.allocateDataSlot());
const args = this.constantPool.getConstLiteral(o.literalArr(i18nAttrArgs), true);
this.creationInstruction(sourceSpan, R3.i18nAttributes, [index, args]);
if (hasBindings) {
this.updateInstruction(sourceSpan, R3.i18nApply, [index]);
}
}
}
private getNamespaceInstruction(namespaceKey: string|null) {
switch (namespaceKey) {
case 'math':
@ -548,10 +588,6 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
stylingBuilder.registerClassAttr(value);
} else {
if (attr.i18n) {
// Place attributes into a separate array for i18n processing, but also keep such
// attributes in the main list to make them available for directive matching at runtime.
// TODO(FW-1248): prevent attributes duplication in `i18nAttributes` and `elementStart`
// arguments
i18nAttrs.push(attr);
} else {
outputAttrs.push(attr);
@ -575,10 +611,6 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
const stylingInputWasSet = stylingBuilder.registerBoundInput(input);
if (!stylingInputWasSet) {
if (input.type === BindingType.Property && input.i18n) {
// Place attributes into a separate array for i18n processing, but also keep such
// attributes in the main list to make them available for directive matching at runtime.
// TODO(FW-1248): prevent attributes duplication in `i18nAttributes` and `elementStart`
// arguments
i18nAttrs.push(input);
} else {
allOtherInputs.push(input);
@ -631,43 +663,8 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
this.creationInstruction(element.sourceSpan, R3.disableBindings);
}
// process i18n element attributes
if (i18nAttrs.length) {
let hasBindings: boolean = false;
const i18nAttrArgs: o.Expression[] = [];
const bindings: ChainableBindingInstruction[] = [];
i18nAttrs.forEach(attr => {
const message = attr.i18n !as i18n.Message;
if (attr instanceof t.TextAttribute) {
i18nAttrArgs.push(o.literal(attr.name), this.i18nTranslate(message));
} else {
const converted = attr.value.visit(this._valueConverter);
this.allocateBindingSlots(converted);
if (converted instanceof Interpolation) {
const placeholders = assembleBoundTextPlaceholders(message);
const params = placeholdersToParams(placeholders);
i18nAttrArgs.push(o.literal(attr.name), this.i18nTranslate(message, params));
converted.expressions.forEach(expression => {
hasBindings = true;
bindings.push({
sourceSpan: element.sourceSpan,
value: () => this.convertPropertyBinding(expression)
});
});
}
}
});
if (bindings.length) {
this.updateInstructionChainWithAdvance(elementIndex, R3.i18nExp, bindings);
}
if (i18nAttrArgs.length) {
const index: o.Expression = o.literal(this.allocateDataSlot());
const args = this.constantPool.getConstLiteral(o.literalArr(i18nAttrArgs), true);
this.creationInstruction(element.sourceSpan, R3.i18nAttributes, [index, args]);
if (hasBindings) {
this.updateInstruction(element.sourceSpan, R3.i18nApply, [index]);
}
}
if (i18nAttrs.length > 0) {
this.i18nAttributesInstruction(elementIndex, i18nAttrs, element.sourceSpan);
}
// Generate Listeners (outputs)
@ -850,6 +847,8 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
this.matchDirectives(NG_TEMPLATE_TAG_NAME, template);
// prepare attributes parameter (including attributes used for directive matching)
// TODO (FW-1942): exclude i18n attributes from the main attribute list and pass them
// as an `i18nAttrs` argument of the `getAttributeExpressions` function below.
const attrsExprs: o.Expression[] = this.getAttributeExpressions(
template.attributes, template.inputs, template.outputs, undefined, template.templateAttrs,
undefined);
@ -894,8 +893,17 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
// handle property bindings e.g. ɵɵproperty('ngForOf', ctx.items), et al;
this.templatePropertyBindings(templateIndex, template.templateAttrs);
// Only add normal input/output binding instructions on explicit ng-template elements.
// Only add normal input/output binding instructions on explicit <ng-template> elements.
if (template.tagName === NG_TEMPLATE_TAG_NAME) {
// Add i18n attributes that may act as inputs to directives. If such attributes are present,
// generate `i18nAttributes` instruction. Note: we generate it only for explicit <ng-template>
// elements, in case of inline templates, corresponding instructions will be generated in the
// nested template function.
const i18nAttrs: t.TextAttribute[] = template.attributes.filter(attr => !!attr.i18n);
if (i18nAttrs.length > 0) {
this.i18nAttributesInstruction(templateIndex, i18nAttrs, template.sourceSpan);
}
// Add the input bindings
this.templatePropertyBindings(templateIndex, template.inputs);
// Generate listeners for directive output

View File

@ -1358,6 +1358,71 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => {
expect(element.title).toBe('Bonjour Angular');
});
it('should process i18n attributes on explicit <ng-template> elements', () => {
const titleDirInstances: TitleDir[] = [];
loadTranslations({[computeMsgId('Hello')]: 'Bonjour'});
@Directive({
selector: '[title]',
})
class TitleDir {
@Input() title = '';
constructor() { titleDirInstances.push(this); }
}
@Component({
selector: 'comp',
template: '<ng-template i18n-title title="Hello"></ng-template>',
})
class Comp {
}
TestBed.configureTestingModule({
declarations: [Comp, TitleDir],
});
const fixture = TestBed.createComponent(Comp);
fixture.detectChanges();
// make sure we only match `TitleDir` once
expect(titleDirInstances.length).toBe(1);
expect(titleDirInstances[0].title).toBe('Bonjour');
});
it('should match directive only once in case i18n attrs are present on inline template', () => {
const titleDirInstances: TitleDir[] = [];
loadTranslations({[computeMsgId('Hello')]: 'Bonjour'});
@Directive({selector: '[title]'})
class TitleDir {
@Input() title: string = '';
constructor(public elRef: ElementRef) { titleDirInstances.push(this); }
}
@Component({
selector: 'my-cmp',
template: `
<button *ngIf="true" i18n-title title="Hello"></button>
`,
})
class Cmp {
}
TestBed.configureTestingModule({
imports: [CommonModule],
declarations: [Cmp, TitleDir],
});
const fixture = TestBed.createComponent(Cmp);
fixture.detectChanges();
// make sure we only match `TitleDir` once and on the right element
expect(titleDirInstances.length).toBe(1);
expect(titleDirInstances[0].elRef.nativeElement instanceof HTMLButtonElement).toBeTruthy();
expect(titleDirInstances[0].title).toBe('Bonjour');
});
it('should apply i18n attributes during second template pass', () => {
loadTranslations({[computeMsgId('Set')]: 'Set'});
@Directive({