feat(ivy): add support for the ngProjectAs attribute (#22498)

PR Close #22498
This commit is contained in:
Pawel Kozlowski 2018-02-28 15:00:58 +01:00 committed by Alex Eagle
parent f86d8ae0fd
commit 2c75acc5b3
6 changed files with 197 additions and 24 deletions

View File

@ -11,7 +11,7 @@ import './ng_dev_mode';
import {assertEqual, assertLessThan, assertNotEqual, assertNotNull, assertNull, assertSame} from './assert'; import {assertEqual, assertLessThan, assertNotEqual, assertNotNull, assertNull, assertSame} from './assert';
import {LContainer, TContainer} from './interfaces/container'; import {LContainer, TContainer} from './interfaces/container';
import {LInjector} from './interfaces/injector'; import {LInjector} from './interfaces/injector';
import {CssSelector, LProjection} from './interfaces/projection'; import {CssSelector, LProjection, NG_PROJECT_AS_ATTR_NAME} from './interfaces/projection';
import {LQueries} from './interfaces/query'; import {LQueries} from './interfaces/query';
import {LView, LViewFlags, LifecycleStage, RootContext, TData, TView} from './interfaces/view'; import {LView, LViewFlags, LifecycleStage, RootContext, TData, TView} from './interfaces/view';
@ -560,8 +560,12 @@ function setUpAttributes(native: RElement, attrs: string[]): void {
const isProc = isProceduralRenderer(renderer); const isProc = isProceduralRenderer(renderer);
for (let i = 0; i < attrs.length; i += 2) { for (let i = 0; i < attrs.length; i += 2) {
isProc ? (renderer as ProceduralRenderer3).setAttribute(native, attrs[i], attrs[i | 1]) : const attrName = attrs[i];
native.setAttribute(attrs[i], attrs[i | 1]); if (attrName !== NG_PROJECT_AS_ATTR_NAME) {
const attrVal = attrs[i + 1];
isProc ? (renderer as ProceduralRenderer3).setAttribute(native, attrName, attrVal) :
native.setAttribute(attrName, attrVal);
}
} }
} }
@ -1279,9 +1283,23 @@ export function directiveRefresh<T>(directiveIndex: number, elementIndex: number
* each projected node belongs (it re-distributes nodes among "buckets" where each "bucket" is * each projected node belongs (it re-distributes nodes among "buckets" where each "bucket" is
* backed by a selector). * backed by a selector).
* *
* @param selectors * This function requires CSS selectors to be provided in 2 forms: parsed (by a compiler) and text,
* un-parsed form.
*
* The parsed form is needed for efficient matching of a node against a given CSS selector.
* The un-parsed, textual form is needed for support of the ngProjectAs attribute.
*
* Having a CSS selector in 2 different formats is not ideal, but alternatives have even more
* drawbacks:
* - having only a textual form would require runtime parsing of CSS selectors;
* - we can't have only a parsed as we can't re-construct textual form from it (as entered by a
* template author).
*
* @param selectors A collection of parsed CSS selectors
* @param rawSelectors A collection of CSS selectors in the raw, un-parsed form
*/ */
export function projectionDef(index: number, selectors?: CssSelector[]): void { export function projectionDef(
index: number, selectors?: CssSelector[], textSelectors?: string[]): void {
const noOfNodeBuckets = selectors ? selectors.length + 1 : 1; const noOfNodeBuckets = selectors ? selectors.length + 1 : 1;
const distributedNodes = new Array<LNode[]>(noOfNodeBuckets); const distributedNodes = new Array<LNode[]>(noOfNodeBuckets);
for (let i = 0; i < noOfNodeBuckets; i++) { for (let i = 0; i < noOfNodeBuckets; i++) {
@ -1296,7 +1314,7 @@ export function projectionDef(index: number, selectors?: CssSelector[]): void {
// - there are selectors defined // - there are selectors defined
// - a node has a tag name / attributes that can be matched // - a node has a tag name / attributes that can be matched
if (selectors && componentChild.tNode) { if (selectors && componentChild.tNode) {
const matchedIdx = matchingSelectorIndex(componentChild.tNode, selectors !); const matchedIdx = matchingSelectorIndex(componentChild.tNode, selectors, textSelectors !);
distributedNodes[matchedIdx].push(componentChild); distributedNodes[matchedIdx].push(componentChild);
} else { } else {
distributedNodes[0].push(componentChild); distributedNodes[0].push(componentChild);

View File

@ -45,6 +45,8 @@ export type CssSelectorWithNegations = [SimpleCssSelector | null, SimpleCssSelec
*/ */
export type CssSelector = CssSelectorWithNegations[]; export type CssSelector = CssSelectorWithNegations[];
export const NG_PROJECT_AS_ATTR_NAME = 'ngProjectAs';
// Note: This hack is necessary so we don't erroneously get a circular dependency // Note: This hack is necessary so we don't erroneously get a circular dependency
// failure based on types. // failure based on types.
export const unusedValueExportToPlacateAjd = 1; export const unusedValueExportToPlacateAjd = 1;

View File

@ -10,7 +10,7 @@ import './ng_dev_mode';
import {assertNotNull} from './assert'; import {assertNotNull} from './assert';
import {TNode, unusedValueExportToPlacateAjd as unused1} from './interfaces/node'; import {TNode, unusedValueExportToPlacateAjd as unused1} from './interfaces/node';
import {CssSelector, CssSelectorWithNegations, SimpleCssSelector, unusedValueExportToPlacateAjd as unused2} from './interfaces/projection'; import {CssSelector, CssSelectorWithNegations, NG_PROJECT_AS_ATTR_NAME, SimpleCssSelector, unusedValueExportToPlacateAjd as unused2} from './interfaces/projection';
const unusedValueToPlacateAjd = unused1 + unused2; const unusedValueToPlacateAjd = unused1 + unused2;
@ -115,13 +115,34 @@ export function isNodeMatchingSelector(tNode: TNode, selector: CssSelector): boo
return false; return false;
} }
export function getProjectAsAttrValue(tNode: TNode): string|null {
const nodeAttrs = tNode.attrs;
if (nodeAttrs != null) {
const ngProjectAsAttrIdx = nodeAttrs.indexOf(NG_PROJECT_AS_ATTR_NAME);
// only check for ngProjectAs in attribute names, don't accidentally match attribute's value
// (attribute names are stored at even indexes)
if ((ngProjectAsAttrIdx & 1) === 0) {
return nodeAttrs[ngProjectAsAttrIdx + 1];
}
}
return null;
}
/** /**
* Checks a given node against matching selectors and returns * Checks a given node against matching selectors and returns
* selector index (or 0 if none matched); * selector index (or 0 if none matched).
*
* This function takes into account the ngProjectAs attribute: if present its value will be compared
* to the raw (un-parsed) CSS selector instead of using standard selector matching logic.
*/ */
export function matchingSelectorIndex(tNode: TNode, selectors: CssSelector[]): number { export function matchingSelectorIndex(
tNode: TNode, selectors: CssSelector[], textSelectors: string[]): number {
const ngProjectAsAttrVal = getProjectAsAttrValue(tNode);
for (let i = 0; i < selectors.length; i++) { for (let i = 0; i < selectors.length; i++) {
if (isNodeMatchingSelector(tNode, selectors[i])) { // if a node has the ngProjectAs attribute match it against unparsed selector
// match a node against a parsed selector only if ngProjectAs attribute is not present
if (ngProjectAsAttrVal === textSelectors[i] ||
ngProjectAsAttrVal === null && isNodeMatchingSelector(tNode, selectors[i])) {
return i + 1; // first matching selector "captures" a given node return i + 1; // first matching selector "captures" a given node
} }
} }

View File

@ -39,8 +39,9 @@ describe('content projection', () => {
} }
// NORMATIVE // NORMATIVE
const $pD_0$: $r3$.ɵCssSelector[] = const $pD_0P$: $r3$.ɵCssSelector[] =
[[[['span', 'title', 'toFirst'], null]], [[['span', 'title', 'toSecond'], null]]]; [[[['span', 'title', 'toFirst'], null]], [[['span', 'title', 'toSecond'], null]]];
const $pD_0R$: string[] = ['span[title=toFirst]', 'span[title=toSecond]'];
// /NORMATIVE // /NORMATIVE
@Component({ @Component({
@ -57,7 +58,7 @@ describe('content projection', () => {
factory: () => new ComplexComponent(), factory: () => new ComplexComponent(),
template: function(ctx: $ComplexComponent$, cm: $boolean$) { template: function(ctx: $ComplexComponent$, cm: $boolean$) {
if (cm) { if (cm) {
$r3$.ɵpD(0, $pD_0$); $r3$.ɵpD(0, $pD_0P$, $pD_0R$);
$r3$.ɵE(1, 'div', ['id', 'first']); $r3$.ɵE(1, 'div', ['id', 'first']);
$r3$.ɵP(2, 0, 1); $r3$.ɵP(2, 0, 1);
$r3$.ɵe(); $r3$.ɵe();
@ -91,4 +92,4 @@ describe('content projection', () => {
} }
}); });
}); });

View File

@ -538,7 +538,8 @@ describe('content projection', () => {
const Child = createComponent('child', function(ctx: any, cm: boolean) { const Child = createComponent('child', function(ctx: any, cm: boolean) {
if (cm) { if (cm) {
projectionDef( projectionDef(
0, [[[['span', 'title', 'toFirst'], null]], [[['span', 'title', 'toSecond'], null]]]); 0, [[[['span', 'title', 'toFirst'], null]], [[['span', 'title', 'toSecond'], null]]],
['span[title=toFirst]', 'span[title=toSecond]']);
elementStart(1, 'div', ['id', 'first']); elementStart(1, 'div', ['id', 'first']);
{ projection(2, 0, 1); } { projection(2, 0, 1); }
elementEnd(); elementEnd();
@ -585,7 +586,8 @@ describe('content projection', () => {
const Child = createComponent('child', function(ctx: any, cm: boolean) { const Child = createComponent('child', function(ctx: any, cm: boolean) {
if (cm) { if (cm) {
projectionDef( projectionDef(
0, [[[['span', 'class', 'toFirst'], null]], [[['span', 'class', 'toSecond'], null]]]); 0, [[[['span', 'class', 'toFirst'], null]], [[['span', 'class', 'toSecond'], null]]],
['span.toFirst', 'span.toSecond']);
elementStart(1, 'div', ['id', 'first']); elementStart(1, 'div', ['id', 'first']);
{ projection(2, 0, 1); } { projection(2, 0, 1); }
elementEnd(); elementEnd();
@ -632,7 +634,8 @@ describe('content projection', () => {
const Child = createComponent('child', function(ctx: any, cm: boolean) { const Child = createComponent('child', function(ctx: any, cm: boolean) {
if (cm) { if (cm) {
projectionDef( projectionDef(
0, [[[['span', 'class', 'toFirst'], null]], [[['span', 'class', 'toSecond'], null]]]); 0, [[[['span', 'class', 'toFirst'], null]], [[['span', 'class', 'toSecond'], null]]],
['span.toFirst', 'span.toSecond']);
elementStart(1, 'div', ['id', 'first']); elementStart(1, 'div', ['id', 'first']);
{ projection(2, 0, 1); } { projection(2, 0, 1); }
elementEnd(); elementEnd();
@ -678,7 +681,9 @@ describe('content projection', () => {
*/ */
const Child = createComponent('child', function(ctx: any, cm: boolean) { const Child = createComponent('child', function(ctx: any, cm: boolean) {
if (cm) { if (cm) {
projectionDef(0, [[[['span'], null]], [[['span', 'class', 'toSecond'], null]]]); projectionDef(
0, [[[['span'], null]], [[['span', 'class', 'toSecond'], null]]],
['span', 'span.toSecond']);
elementStart(1, 'div', ['id', 'first']); elementStart(1, 'div', ['id', 'first']);
{ projection(2, 0, 1); } { projection(2, 0, 1); }
elementEnd(); elementEnd();
@ -724,7 +729,7 @@ describe('content projection', () => {
*/ */
const Child = createComponent('child', function(ctx: any, cm: boolean) { const Child = createComponent('child', function(ctx: any, cm: boolean) {
if (cm) { if (cm) {
projectionDef(0, [[[['span', 'class', 'toFirst'], null]]]); projectionDef(0, [[[['span', 'class', 'toFirst'], null]]], ['span.toFirst']);
elementStart(1, 'div', ['id', 'first']); elementStart(1, 'div', ['id', 'first']);
{ projection(2, 0, 1); } { projection(2, 0, 1); }
elementEnd(); elementEnd();
@ -771,7 +776,7 @@ describe('content projection', () => {
*/ */
const Child = createComponent('child', function(ctx: any, cm: boolean) { const Child = createComponent('child', function(ctx: any, cm: boolean) {
if (cm) { if (cm) {
projectionDef(0, [[[['span', 'class', 'toSecond'], null]]]); projectionDef(0, [[[['span', 'class', 'toSecond'], null]]], ['span.toSecond']);
elementStart(1, 'div', ['id', 'first']); elementStart(1, 'div', ['id', 'first']);
{ projection(2, 0); } { projection(2, 0); }
elementEnd(); elementEnd();
@ -825,7 +830,7 @@ describe('content projection', () => {
*/ */
const GrandChild = createComponent('grand-child', function(ctx: any, cm: boolean) { const GrandChild = createComponent('grand-child', function(ctx: any, cm: boolean) {
if (cm) { if (cm) {
projectionDef(0, [[[['span'], null]]]); projectionDef(0, [[[['span'], null]]], ['span']);
projection(1, 0, 1); projection(1, 0, 1);
elementStart(2, 'hr'); elementStart(2, 'hr');
elementEnd(); elementEnd();
@ -891,7 +896,9 @@ describe('content projection', () => {
*/ */
const Card = createComponent('card', function(ctx: any, cm: boolean) { const Card = createComponent('card', function(ctx: any, cm: boolean) {
if (cm) { if (cm) {
projectionDef(0, [[[['', 'card-title', ''], null]], [[['', 'card-content', ''], null]]]); projectionDef(
0, [[[['', 'card-title', ''], null]], [[['', 'card-content', ''], null]]],
['[card-title]', '[card-content]']);
projection(1, 0, 1); projection(1, 0, 1);
elementStart(2, 'hr'); elementStart(2, 'hr');
elementEnd(); elementEnd();
@ -942,6 +949,108 @@ describe('content projection', () => {
'<card-with-title><card><h1 card-title="">Title</h1><hr>content</card></card-with-title>'); '<card-with-title><card><h1 card-title="">Title</h1><hr>content</card></card-with-title>');
}); });
it('should support ngProjectAs on elements (including <ng-content>)', () => {
/**
* <ng-content select="[card-title]"></ng-content>
* <hr>
* <ng-content select="[card-content]"></ng-content>
*/
const Card = createComponent('card', function(ctx: any, cm: boolean) {
if (cm) {
projectionDef(
0, [[[['', 'card-title', ''], null]], [[['', 'card-content', ''], null]]],
['[card-title]', '[card-content]']);
projection(1, 0, 1);
elementStart(2, 'hr');
elementEnd();
projection(3, 0, 2);
}
});
/**
* <card>
* <h1 ngProjectAs="[card-title]>Title</h1>
* <ng-content ngProjectAs="[card-content]"></ng-content>
* </card>
*/
const CardWithTitle = createComponent('card-with-title', function(ctx: any, cm: boolean) {
if (cm) {
projectionDef(0);
elementStart(1, Card);
{
elementStart(3, 'h1', ['ngProjectAs', '[card-title]']);
{ text(4, 'Title'); }
elementEnd();
projection(5, 0, 0, ['ngProjectAs', '[card-content]']);
}
elementEnd();
Card.ngComponentDef.h(2, 1);
directiveRefresh(2, 1);
}
});
/**
* <card-with-title>
* content
* </card-with-title>
*/
const App = createComponent('app', function(ctx: any, cm: boolean) {
if (cm) {
elementStart(0, CardWithTitle);
{ text(2, 'content'); }
elementEnd();
}
CardWithTitle.ngComponentDef.h(1, 0);
directiveRefresh(1, 0);
});
const app = renderComponent(App);
expect(toHtml(app))
.toEqual('<card-with-title><card><h1>Title</h1><hr>content</card></card-with-title>');
});
it('should not match selectors against node having ngProjectAs attribute', function() {
/**
* <ng-content select="div"></ng-content>
*/
const Child = createComponent('child', function(ctx: any, cm: boolean) {
if (cm) {
projectionDef(0, [[[['div'], null]]], ['div']);
projection(1, 0, 1);
}
});
/**
* <child>
* <div ngProjectAs="span">should not project</div>
* <div>should project</div>
* </child>
*/
const Parent = createComponent('parent', function(ctx: any, cm: boolean) {
if (cm) {
elementStart(0, Child);
{
elementStart(2, 'div', ['ngProjectAs', 'span']);
{ text(3, 'should not project'); }
elementEnd();
elementStart(4, 'div');
{ text(5, 'should project'); }
elementEnd();
}
elementEnd();
}
Child.ngComponentDef.h(1, 0);
directiveRefresh(1, 0);
});
const parent = renderComponent(Parent);
expect(toHtml(parent)).toEqual('<child><div>should project</div></child>');
});
it('should match selectors against projected containers', () => { it('should match selectors against projected containers', () => {
/** /**
@ -951,7 +1060,7 @@ describe('content projection', () => {
*/ */
const Child = createComponent('child', function(ctx: any, cm: boolean) { const Child = createComponent('child', function(ctx: any, cm: boolean) {
if (cm) { if (cm) {
projectionDef(0, [[[['div'], null]]]); projectionDef(0, [[[['div'], null]]], ['div']);
elementStart(1, 'span'); elementStart(1, 'span');
{ projection(2, 0, 1); } { projection(2, 0, 1); }
elementEnd(); elementEnd();

View File

@ -7,8 +7,8 @@
*/ */
import {TNode} from '../../src/render3/interfaces/node'; import {TNode} from '../../src/render3/interfaces/node';
import {CssSelector, CssSelectorWithNegations, SimpleCssSelector} from '../../src/render3/interfaces/projection'; import {CssSelector, CssSelectorWithNegations, NG_PROJECT_AS_ATTR_NAME, SimpleCssSelector} from '../../src/render3/interfaces/projection';
import {isNodeMatchingSelector, isNodeMatchingSelectorWithNegations, isNodeMatchingSimpleSelector} from '../../src/render3/node_selector_matcher'; import {getProjectAsAttrValue, isNodeMatchingSelector, isNodeMatchingSelectorWithNegations, isNodeMatchingSimpleSelector} from '../../src/render3/node_selector_matcher';
function testLStaticData(tagName: string, attrs: string[] | null): TNode { function testLStaticData(tagName: string, attrs: string[] | null): TNode {
return { return {
@ -183,4 +183,26 @@ describe('css selector matching', () => {
}); });
}); });
describe('reading the ngProjectAs attribute value', function() {
function testTNode(attrs: string[] | null) { return testLStaticData('tag', attrs); }
it('should get ngProjectAs value if present', function() {
expect(getProjectAsAttrValue(testTNode([NG_PROJECT_AS_ATTR_NAME, 'tag[foo=bar]'])))
.toBe('tag[foo=bar]');
});
it('should return null if there are no attributes',
function() { expect(getProjectAsAttrValue(testTNode(null))).toBe(null); });
it('should return if ngProjectAs is not present', function() {
expect(getProjectAsAttrValue(testTNode(['foo', 'bar']))).toBe(null);
});
it('should not accidentally identify ngProjectAs in attribute values', function() {
expect(getProjectAsAttrValue(testTNode(['foo', NG_PROJECT_AS_ATTR_NAME]))).toBe(null);
});
});
}); });