The `R3TargetBinder` accepts an interface for directive metadata which declares types for `input` and `output` objects. These types convey the mapping between the property names for an input or output and the corresponding property name on the component class. Due to `R3TargetBinder`'s requirements, this mapping was specified with property names as keys and field names as values. However, because of duck typing, this interface was accidentally satisifed by the opposite mapping, of field names to property names, that was produced in other parts of the compiler. This form more naturally represents the data model for inputs. Rather than accept the field -> property mapping and invert it, this commit introduces a new abstraction for such mappings which is bidirectional, eliminating the ambiguous plain object type. This mapping uses new, unambiguous terminology ("class property name" and "binding property name") and can be used to satisfy both the needs of the binder as well as those of the template type-checker (field -> property). A new test ensures that the input/output metadata produced by the compiler during analysis is directly compatible with the binder via this unambiguous new interface. PR Close #38685
198 lines
7.9 KiB
TypeScript
198 lines
7.9 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC 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 * as e from '../../../src/expression_parser/ast';
|
|
import * as a from '../../../src/render3/r3_ast';
|
|
import {DirectiveMeta, InputOutputPropertySet} from '../../../src/render3/view/t2_api';
|
|
import {R3TargetBinder} from '../../../src/render3/view/t2_binder';
|
|
import {parseTemplate} from '../../../src/render3/view/template';
|
|
import {CssSelector, SelectorMatcher} from '../../../src/selector';
|
|
|
|
import {findExpression} from './util';
|
|
|
|
/**
|
|
* A `InputOutputPropertySet` which only uses an identity mapping for fields and properties.
|
|
*/
|
|
class IdentityInputMapping implements InputOutputPropertySet {
|
|
private names: Set<string>;
|
|
|
|
constructor(names: string[]) {
|
|
this.names = new Set(names);
|
|
}
|
|
|
|
hasBindingPropertyName(propertyName: string): boolean {
|
|
return this.names.has(propertyName);
|
|
}
|
|
}
|
|
|
|
function makeSelectorMatcher(): SelectorMatcher<DirectiveMeta> {
|
|
const matcher = new SelectorMatcher<DirectiveMeta>();
|
|
matcher.addSelectables(CssSelector.parse('[ngFor][ngForOf]'), {
|
|
name: 'NgFor',
|
|
exportAs: null,
|
|
inputs: new IdentityInputMapping(['ngForOf']),
|
|
outputs: new IdentityInputMapping([]),
|
|
isComponent: false,
|
|
});
|
|
matcher.addSelectables(CssSelector.parse('[dir]'), {
|
|
name: 'Dir',
|
|
exportAs: null,
|
|
inputs: new IdentityInputMapping([]),
|
|
outputs: new IdentityInputMapping([]),
|
|
isComponent: false,
|
|
});
|
|
matcher.addSelectables(CssSelector.parse('[hasOutput]'), {
|
|
name: 'HasOutput',
|
|
exportAs: null,
|
|
inputs: new IdentityInputMapping([]),
|
|
outputs: new IdentityInputMapping(['outputBinding']),
|
|
isComponent: false,
|
|
});
|
|
matcher.addSelectables(CssSelector.parse('[hasInput]'), {
|
|
name: 'HasInput',
|
|
exportAs: null,
|
|
inputs: new IdentityInputMapping(['inputBinding']),
|
|
outputs: new IdentityInputMapping([]),
|
|
isComponent: false,
|
|
});
|
|
return matcher;
|
|
}
|
|
|
|
describe('t2 binding', () => {
|
|
it('should bind a simple template', () => {
|
|
const template = parseTemplate('<div *ngFor="let item of items">{{item.name}}</div>', '', {});
|
|
const binder = new R3TargetBinder(new SelectorMatcher<DirectiveMeta>());
|
|
const res = binder.bind({template: template.nodes});
|
|
|
|
const itemBinding =
|
|
(findExpression(template.nodes, '{{item.name}}')! as e.Interpolation).expressions[0] as
|
|
e.PropertyRead;
|
|
const item = itemBinding.receiver;
|
|
const itemTarget = res.getExpressionTarget(item);
|
|
if (!(itemTarget instanceof a.Variable)) {
|
|
return fail('Expected item to point to a Variable');
|
|
}
|
|
expect(itemTarget.value).toBe('$implicit');
|
|
const itemTemplate = res.getTemplateOfSymbol(itemTarget);
|
|
expect(itemTemplate).not.toBeNull();
|
|
expect(res.getNestingLevel(itemTemplate!)).toBe(1);
|
|
});
|
|
|
|
it('should match directives when binding a simple template', () => {
|
|
const template = parseTemplate('<div *ngFor="let item of items">{{item.name}}</div>', '', {});
|
|
const binder = new R3TargetBinder(makeSelectorMatcher());
|
|
const res = binder.bind({template: template.nodes});
|
|
const tmpl = template.nodes[0] as a.Template;
|
|
const directives = res.getDirectivesOfNode(tmpl)!;
|
|
expect(directives).not.toBeNull();
|
|
expect(directives.length).toBe(1);
|
|
expect(directives[0].name).toBe('NgFor');
|
|
});
|
|
|
|
it('should match directives on namespaced elements', () => {
|
|
const template = parseTemplate('<svg><text dir>SVG</text></svg>', '', {});
|
|
const matcher = new SelectorMatcher<DirectiveMeta>();
|
|
matcher.addSelectables(CssSelector.parse('text[dir]'), {
|
|
name: 'Dir',
|
|
exportAs: null,
|
|
inputs: new IdentityInputMapping([]),
|
|
outputs: new IdentityInputMapping([]),
|
|
isComponent: false,
|
|
});
|
|
const binder = new R3TargetBinder(matcher);
|
|
const res = binder.bind({template: template.nodes});
|
|
const svgNode = template.nodes[0] as a.Element;
|
|
const textNode = svgNode.children[0] as a.Element;
|
|
const directives = res.getDirectivesOfNode(textNode)!;
|
|
expect(directives).not.toBeNull();
|
|
expect(directives.length).toBe(1);
|
|
expect(directives[0].name).toBe('Dir');
|
|
});
|
|
|
|
it('should not match directives intended for an element on a microsyntax template', () => {
|
|
const template = parseTemplate('<div *ngFor="let item of items" dir></div>', '', {});
|
|
const binder = new R3TargetBinder(makeSelectorMatcher());
|
|
const res = binder.bind({template: template.nodes});
|
|
const tmpl = template.nodes[0] as a.Template;
|
|
const tmplDirectives = res.getDirectivesOfNode(tmpl)!;
|
|
expect(tmplDirectives).not.toBeNull();
|
|
expect(tmplDirectives.length).toBe(1);
|
|
expect(tmplDirectives[0].name).toBe('NgFor');
|
|
const elDirectives = res.getDirectivesOfNode(tmpl.children[0] as a.Element)!;
|
|
expect(elDirectives).not.toBeNull();
|
|
expect(elDirectives.length).toBe(1);
|
|
expect(elDirectives[0].name).toBe('Dir');
|
|
});
|
|
|
|
describe('matching inputs to consuming directives', () => {
|
|
it('should work for bound attributes', () => {
|
|
const template = parseTemplate('<div hasInput [inputBinding]="myValue"></div>', '', {});
|
|
const binder = new R3TargetBinder(makeSelectorMatcher());
|
|
const res = binder.bind({template: template.nodes});
|
|
const el = template.nodes[0] as a.Element;
|
|
const attr = el.inputs[0];
|
|
const consumer = res.getConsumerOfBinding(attr) as DirectiveMeta;
|
|
expect(consumer.name).toBe('HasInput');
|
|
});
|
|
|
|
it('should work for text attributes on elements', () => {
|
|
const template = parseTemplate('<div hasInput inputBinding="text"></div>', '', {});
|
|
const binder = new R3TargetBinder(makeSelectorMatcher());
|
|
const res = binder.bind({template: template.nodes});
|
|
const el = template.nodes[0] as a.Element;
|
|
const attr = el.attributes[1];
|
|
const consumer = res.getConsumerOfBinding(attr) as DirectiveMeta;
|
|
expect(consumer.name).toBe('HasInput');
|
|
});
|
|
|
|
it('should work for text attributes on templates', () => {
|
|
const template =
|
|
parseTemplate('<ng-template hasInput inputBinding="text"></ng-template>', '', {});
|
|
const binder = new R3TargetBinder(makeSelectorMatcher());
|
|
const res = binder.bind({template: template.nodes});
|
|
const el = template.nodes[0] as a.Element;
|
|
const attr = el.attributes[1];
|
|
const consumer = res.getConsumerOfBinding(attr) as DirectiveMeta;
|
|
expect(consumer.name).toBe('HasInput');
|
|
});
|
|
|
|
it('should bind to the encompassing node when no directive input is matched', () => {
|
|
const template = parseTemplate('<span dir></span>', '', {});
|
|
const binder = new R3TargetBinder(makeSelectorMatcher());
|
|
const res = binder.bind({template: template.nodes});
|
|
const el = template.nodes[0] as a.Element;
|
|
const attr = el.attributes[0];
|
|
const consumer = res.getConsumerOfBinding(attr);
|
|
expect(consumer).toEqual(el);
|
|
});
|
|
});
|
|
|
|
describe('matching outputs to consuming directives', () => {
|
|
it('should work for bound events', () => {
|
|
const template =
|
|
parseTemplate('<div hasOutput (outputBinding)="myHandler($event)"></div>', '', {});
|
|
const binder = new R3TargetBinder(makeSelectorMatcher());
|
|
const res = binder.bind({template: template.nodes});
|
|
const el = template.nodes[0] as a.Element;
|
|
const attr = el.outputs[0];
|
|
const consumer = res.getConsumerOfBinding(attr) as DirectiveMeta;
|
|
expect(consumer.name).toBe('HasOutput');
|
|
});
|
|
|
|
it('should bind to the encompassing node when no directive output is matched', () => {
|
|
const template = parseTemplate('<span dir (fakeOutput)="myHandler($event)"></span>', '', {});
|
|
const binder = new R3TargetBinder(makeSelectorMatcher());
|
|
const res = binder.bind({template: template.nodes});
|
|
const el = template.nodes[0] as a.Element;
|
|
const attr = el.outputs[0];
|
|
const consumer = res.getConsumerOfBinding(attr);
|
|
expect(consumer).toEqual(el);
|
|
});
|
|
});
|
|
});
|