From 914797a8ff7b7addaf28524f185016f7e3ffc0a9 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 14 Mar 2017 00:34:53 +0000 Subject: [PATCH] feat(upgrade): support multi-slot projection in upgrade/static (#14282) Closes #14261 --- packages/examples/upgrade/static/ts/module.ts | 7 +- packages/upgrade/src/common/angular1.ts | 2 +- .../src/common/compiler_helpers/README.md | 7 + .../createElementCssSelector.ts | 55 +++ .../compiler_helpers/ml_parser/html_tags.ts | 129 ++++++ .../common/compiler_helpers/ml_parser/tags.ts | 310 +++++++++++++++ .../src/common/compiler_helpers/selector.ts | 368 ++++++++++++++++++ packages/upgrade/src/common/component_info.ts | 1 + .../upgrade/src/common/downgrade_component.ts | 15 +- .../src/common/downgrade_component_adapter.ts | 65 +++- .../src/common/ng_content_selector_helper.ts | 24 ++ .../src/dynamic/ng_content_selector_helper.ts | 24 ++ .../upgrade/src/dynamic/upgrade_adapter.ts | 6 +- packages/upgrade/src/static/upgrade_module.ts | 4 +- .../downgrade_component_adapter_spec.ts | 96 +++++ packages/upgrade/test/common/test_helpers.ts | 6 + packages/upgrade/test/dynamic/test_helpers.ts | 6 - .../integration/content_projection_spec.ts | 34 ++ .../upgrade/typings/static/index.d.ts | 1 + 19 files changed, 1135 insertions(+), 25 deletions(-) create mode 100644 packages/upgrade/src/common/compiler_helpers/README.md create mode 100644 packages/upgrade/src/common/compiler_helpers/createElementCssSelector.ts create mode 100644 packages/upgrade/src/common/compiler_helpers/ml_parser/html_tags.ts create mode 100644 packages/upgrade/src/common/compiler_helpers/ml_parser/tags.ts create mode 100644 packages/upgrade/src/common/compiler_helpers/selector.ts create mode 100644 packages/upgrade/src/common/ng_content_selector_helper.ts create mode 100644 packages/upgrade/src/dynamic/ng_content_selector_helper.ts create mode 100644 packages/upgrade/test/common/downgrade_component_adapter_spec.ts diff --git a/packages/examples/upgrade/static/ts/module.ts b/packages/examples/upgrade/static/ts/module.ts index 0f6a83f831..6c6d0aeb6f 100644 --- a/packages/examples/upgrade/static/ts/module.ts +++ b/packages/examples/upgrade/static/ts/module.ts @@ -22,8 +22,8 @@ interface Hero { selector: 'ng2-heroes', // This template uses the upgraded `ng1-hero` component // Note that because its element is compiled by Angular we must use camelCased attribute names - template: `

Heroes

-

+ template: `
+
Super Hero
@@ -167,7 +167,8 @@ ng1AppModule.component('exampleApp', { // inputs and outputs template: ` - There are {{ $ctrl.heroesService.heroes.length }} heroes. +

Heroes

+

There are {{ $ctrl.heroesService.heroes.length }} heroes.

` }); // #enddocregion diff --git a/packages/upgrade/src/common/angular1.ts b/packages/upgrade/src/common/angular1.ts index f8fbe433cb..267e8dbac8 100644 --- a/packages/upgrade/src/common/angular1.ts +++ b/packages/upgrade/src/common/angular1.ts @@ -205,7 +205,7 @@ let angular: { bootstrap: (e: Element, modules: (string | IInjectable)[], config: IAngularBootstrapConfig) => void, module: (prefix: string, dependencies?: string[]) => IModule, - element: (e: Element) => IAugmentedJQuery, + element: (e: Element | string) => IAugmentedJQuery, version: {major: number}, resumeBootstrap?: () => void, getTestability: (e: Element) => ITestabilityService } = { diff --git a/packages/upgrade/src/common/compiler_helpers/README.md b/packages/upgrade/src/common/compiler_helpers/README.md new file mode 100644 index 0000000000..7c53b572b3 --- /dev/null +++ b/packages/upgrade/src/common/compiler_helpers/README.md @@ -0,0 +1,7 @@ +The following code has been copied from the Angular compiler to be used in the upgrade library without +the need to import the entire compiler: + +* `selector.ts` +* `ml_parser/html_tags.ts` +* `ml_parser/tags.ts` +* `createElementCssSelector.ts` \ No newline at end of file diff --git a/packages/upgrade/src/common/compiler_helpers/createElementCssSelector.ts b/packages/upgrade/src/common/compiler_helpers/createElementCssSelector.ts new file mode 100644 index 0000000000..e964ac5b27 --- /dev/null +++ b/packages/upgrade/src/common/compiler_helpers/createElementCssSelector.ts @@ -0,0 +1,55 @@ +/** + * @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 {CssSelector} from './selector'; + +/* + * The following items are copied from the Angular Compiler to be used here + * without the need to import the entire compiler into the build + */ + +const CLASS_ATTR = 'class'; + +export function createElementCssSelector( + elementName: string, attributes: [string, string][]): CssSelector { + const cssSelector = new CssSelector(); + const elNameNoNs = splitNsName(elementName)[1]; + + cssSelector.setElement(elNameNoNs); + + for (let i = 0; i < attributes.length; i++) { + const attrName = attributes[i][0]; + const attrNameNoNs = splitNsName(attrName)[1]; + const attrValue = attributes[i][1]; + + cssSelector.addAttribute(attrNameNoNs, attrValue); + if (attrName.toLowerCase() == CLASS_ATTR) { + const classes = splitClasses(attrValue); + classes.forEach(className => cssSelector.addClassName(className)); + } + } + return cssSelector; +} + +export function splitNsName(elementName: string): [string, string] { + if (elementName[0] != ':') { + return [null, elementName]; + } + + const colonIndex = elementName.indexOf(':', 1); + + if (colonIndex == -1) { + throw new Error(`Unsupported format "${elementName}" expecting ":namespace:name"`); + } + + return [elementName.slice(1, colonIndex), elementName.slice(colonIndex + 1)]; +} + +export function splitClasses(classAttrValue: string): string[] { + return classAttrValue.trim().split(/\s+/g); +} diff --git a/packages/upgrade/src/common/compiler_helpers/ml_parser/html_tags.ts b/packages/upgrade/src/common/compiler_helpers/ml_parser/html_tags.ts new file mode 100644 index 0000000000..65a3cc8f6b --- /dev/null +++ b/packages/upgrade/src/common/compiler_helpers/ml_parser/html_tags.ts @@ -0,0 +1,129 @@ +/** + * @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 {TagContentType, TagDefinition} from './tags'; + +export class HtmlTagDefinition implements TagDefinition { + private closedByChildren: {[key: string]: boolean} = {}; + + closedByParent: boolean = false; + requiredParents: {[key: string]: boolean}; + parentToAdd: string; + implicitNamespacePrefix: string; + contentType: TagContentType; + isVoid: boolean; + ignoreFirstLf: boolean; + canSelfClose: boolean = false; + + constructor( + {closedByChildren, requiredParents, implicitNamespacePrefix, + contentType = TagContentType.PARSABLE_DATA, closedByParent = false, isVoid = false, + ignoreFirstLf = false}: { + closedByChildren?: string[], + closedByParent?: boolean, + requiredParents?: string[], + implicitNamespacePrefix?: string, + contentType?: TagContentType, + isVoid?: boolean, + ignoreFirstLf?: boolean + } = {}) { + if (closedByChildren && closedByChildren.length > 0) { + closedByChildren.forEach(tagName => this.closedByChildren[tagName] = true); + } + this.isVoid = isVoid; + this.closedByParent = closedByParent || isVoid; + if (requiredParents && requiredParents.length > 0) { + this.requiredParents = {}; + // The first parent is the list is automatically when none of the listed parents are present + this.parentToAdd = requiredParents[0]; + requiredParents.forEach(tagName => this.requiredParents[tagName] = true); + } + this.implicitNamespacePrefix = implicitNamespacePrefix; + this.contentType = contentType; + this.ignoreFirstLf = ignoreFirstLf; + } + + requireExtraParent(currentParent: string): boolean { + if (!this.requiredParents) { + return false; + } + + if (!currentParent) { + return true; + } + + const lcParent = currentParent.toLowerCase(); + const isParentTemplate = lcParent === 'template' || currentParent === 'ng-template'; + return !isParentTemplate && this.requiredParents[lcParent] != true; + } + + isClosedByChild(name: string): boolean { + return this.isVoid || name.toLowerCase() in this.closedByChildren; + } +} + +// see http://www.w3.org/TR/html51/syntax.html#optional-tags +// This implementation does not fully conform to the HTML5 spec. +const TAG_DEFINITIONS: {[key: string]: HtmlTagDefinition} = { + 'base': new HtmlTagDefinition({isVoid: true}), + 'meta': new HtmlTagDefinition({isVoid: true}), + 'area': new HtmlTagDefinition({isVoid: true}), + 'embed': new HtmlTagDefinition({isVoid: true}), + 'link': new HtmlTagDefinition({isVoid: true}), + 'img': new HtmlTagDefinition({isVoid: true}), + 'input': new HtmlTagDefinition({isVoid: true}), + 'param': new HtmlTagDefinition({isVoid: true}), + 'hr': new HtmlTagDefinition({isVoid: true}), + 'br': new HtmlTagDefinition({isVoid: true}), + 'source': new HtmlTagDefinition({isVoid: true}), + 'track': new HtmlTagDefinition({isVoid: true}), + 'wbr': new HtmlTagDefinition({isVoid: true}), + 'p': new HtmlTagDefinition({ + closedByChildren: [ + 'address', 'article', 'aside', 'blockquote', 'div', 'dl', 'fieldset', 'footer', 'form', + 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', + 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul' + ], + closedByParent: true + }), + 'thead': new HtmlTagDefinition({closedByChildren: ['tbody', 'tfoot']}), + 'tbody': new HtmlTagDefinition({closedByChildren: ['tbody', 'tfoot'], closedByParent: true}), + 'tfoot': new HtmlTagDefinition({closedByChildren: ['tbody'], closedByParent: true}), + 'tr': new HtmlTagDefinition({ + closedByChildren: ['tr'], + requiredParents: ['tbody', 'tfoot', 'thead'], + closedByParent: true + }), + 'td': new HtmlTagDefinition({closedByChildren: ['td', 'th'], closedByParent: true}), + 'th': new HtmlTagDefinition({closedByChildren: ['td', 'th'], closedByParent: true}), + 'col': new HtmlTagDefinition({requiredParents: ['colgroup'], isVoid: true}), + 'svg': new HtmlTagDefinition({implicitNamespacePrefix: 'svg'}), + 'math': new HtmlTagDefinition({implicitNamespacePrefix: 'math'}), + 'li': new HtmlTagDefinition({closedByChildren: ['li'], closedByParent: true}), + 'dt': new HtmlTagDefinition({closedByChildren: ['dt', 'dd']}), + 'dd': new HtmlTagDefinition({closedByChildren: ['dt', 'dd'], closedByParent: true}), + 'rb': new HtmlTagDefinition({closedByChildren: ['rb', 'rt', 'rtc', 'rp'], closedByParent: true}), + 'rt': new HtmlTagDefinition({closedByChildren: ['rb', 'rt', 'rtc', 'rp'], closedByParent: true}), + 'rtc': new HtmlTagDefinition({closedByChildren: ['rb', 'rtc', 'rp'], closedByParent: true}), + 'rp': new HtmlTagDefinition({closedByChildren: ['rb', 'rt', 'rtc', 'rp'], closedByParent: true}), + 'optgroup': new HtmlTagDefinition({closedByChildren: ['optgroup'], closedByParent: true}), + 'option': new HtmlTagDefinition({closedByChildren: ['option', 'optgroup'], closedByParent: true}), + 'pre': new HtmlTagDefinition({ignoreFirstLf: true}), + 'listing': new HtmlTagDefinition({ignoreFirstLf: true}), + 'style': new HtmlTagDefinition({contentType: TagContentType.RAW_TEXT}), + 'script': new HtmlTagDefinition({contentType: TagContentType.RAW_TEXT}), + 'title': new HtmlTagDefinition({contentType: TagContentType.ESCAPABLE_RAW_TEXT}), + 'textarea': + new HtmlTagDefinition({contentType: TagContentType.ESCAPABLE_RAW_TEXT, ignoreFirstLf: true}), +}; + +const _DEFAULT_TAG_DEFINITION = new HtmlTagDefinition(); + +export function getHtmlTagDefinition(tagName: string): HtmlTagDefinition { + return TAG_DEFINITIONS[tagName.toLowerCase()] || _DEFAULT_TAG_DEFINITION; +} \ No newline at end of file diff --git a/packages/upgrade/src/common/compiler_helpers/ml_parser/tags.ts b/packages/upgrade/src/common/compiler_helpers/ml_parser/tags.ts new file mode 100644 index 0000000000..f83c8fd848 --- /dev/null +++ b/packages/upgrade/src/common/compiler_helpers/ml_parser/tags.ts @@ -0,0 +1,310 @@ +/** + * @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 + */ + +export enum TagContentType { + RAW_TEXT, + ESCAPABLE_RAW_TEXT, + PARSABLE_DATA +} + +// TODO(vicb): read-only when TS supports it +export interface TagDefinition { + closedByParent: boolean; + requiredParents: {[key: string]: boolean}; + parentToAdd: string; + implicitNamespacePrefix: string; + contentType: TagContentType; + isVoid: boolean; + ignoreFirstLf: boolean; + canSelfClose: boolean; + + requireExtraParent(currentParent: string): boolean; + + isClosedByChild(name: string): boolean; +} + +export function splitNsName(elementName: string): [string, string] { + if (elementName[0] != ':') { + return [null, elementName]; + } + + const colonIndex = elementName.indexOf(':', 1); + + if (colonIndex == -1) { + throw new Error(`Unsupported format "${elementName}" expecting ":namespace:name"`); + } + + return [elementName.slice(1, colonIndex), elementName.slice(colonIndex + 1)]; +} + +export function getNsPrefix(fullName: string): string { + return fullName === null ? null : splitNsName(fullName)[0]; +} + +export function mergeNsAndName(prefix: string, localName: string): string { + return prefix ? `:${prefix}:${localName}` : localName; +} + +// see http://www.w3.org/TR/html51/syntax.html#named-character-references +// see https://html.spec.whatwg.org/multipage/entities.json +// This list is not exhaustive to keep the compiler footprint low. +// The `{` / `ƫ` syntax should be used when the named character reference does not exist. +export const NAMED_ENTITIES: {[k: string]: string} = { + 'Aacute': '\u00C1', + 'aacute': '\u00E1', + 'Acirc': '\u00C2', + 'acirc': '\u00E2', + 'acute': '\u00B4', + 'AElig': '\u00C6', + 'aelig': '\u00E6', + 'Agrave': '\u00C0', + 'agrave': '\u00E0', + 'alefsym': '\u2135', + 'Alpha': '\u0391', + 'alpha': '\u03B1', + 'amp': '&', + 'and': '\u2227', + 'ang': '\u2220', + 'apos': '\u0027', + 'Aring': '\u00C5', + 'aring': '\u00E5', + 'asymp': '\u2248', + 'Atilde': '\u00C3', + 'atilde': '\u00E3', + 'Auml': '\u00C4', + 'auml': '\u00E4', + 'bdquo': '\u201E', + 'Beta': '\u0392', + 'beta': '\u03B2', + 'brvbar': '\u00A6', + 'bull': '\u2022', + 'cap': '\u2229', + 'Ccedil': '\u00C7', + 'ccedil': '\u00E7', + 'cedil': '\u00B8', + 'cent': '\u00A2', + 'Chi': '\u03A7', + 'chi': '\u03C7', + 'circ': '\u02C6', + 'clubs': '\u2663', + 'cong': '\u2245', + 'copy': '\u00A9', + 'crarr': '\u21B5', + 'cup': '\u222A', + 'curren': '\u00A4', + 'dagger': '\u2020', + 'Dagger': '\u2021', + 'darr': '\u2193', + 'dArr': '\u21D3', + 'deg': '\u00B0', + 'Delta': '\u0394', + 'delta': '\u03B4', + 'diams': '\u2666', + 'divide': '\u00F7', + 'Eacute': '\u00C9', + 'eacute': '\u00E9', + 'Ecirc': '\u00CA', + 'ecirc': '\u00EA', + 'Egrave': '\u00C8', + 'egrave': '\u00E8', + 'empty': '\u2205', + 'emsp': '\u2003', + 'ensp': '\u2002', + 'Epsilon': '\u0395', + 'epsilon': '\u03B5', + 'equiv': '\u2261', + 'Eta': '\u0397', + 'eta': '\u03B7', + 'ETH': '\u00D0', + 'eth': '\u00F0', + 'Euml': '\u00CB', + 'euml': '\u00EB', + 'euro': '\u20AC', + 'exist': '\u2203', + 'fnof': '\u0192', + 'forall': '\u2200', + 'frac12': '\u00BD', + 'frac14': '\u00BC', + 'frac34': '\u00BE', + 'frasl': '\u2044', + 'Gamma': '\u0393', + 'gamma': '\u03B3', + 'ge': '\u2265', + 'gt': '>', + 'harr': '\u2194', + 'hArr': '\u21D4', + 'hearts': '\u2665', + 'hellip': '\u2026', + 'Iacute': '\u00CD', + 'iacute': '\u00ED', + 'Icirc': '\u00CE', + 'icirc': '\u00EE', + 'iexcl': '\u00A1', + 'Igrave': '\u00CC', + 'igrave': '\u00EC', + 'image': '\u2111', + 'infin': '\u221E', + 'int': '\u222B', + 'Iota': '\u0399', + 'iota': '\u03B9', + 'iquest': '\u00BF', + 'isin': '\u2208', + 'Iuml': '\u00CF', + 'iuml': '\u00EF', + 'Kappa': '\u039A', + 'kappa': '\u03BA', + 'Lambda': '\u039B', + 'lambda': '\u03BB', + 'lang': '\u27E8', + 'laquo': '\u00AB', + 'larr': '\u2190', + 'lArr': '\u21D0', + 'lceil': '\u2308', + 'ldquo': '\u201C', + 'le': '\u2264', + 'lfloor': '\u230A', + 'lowast': '\u2217', + 'loz': '\u25CA', + 'lrm': '\u200E', + 'lsaquo': '\u2039', + 'lsquo': '\u2018', + 'lt': '<', + 'macr': '\u00AF', + 'mdash': '\u2014', + 'micro': '\u00B5', + 'middot': '\u00B7', + 'minus': '\u2212', + 'Mu': '\u039C', + 'mu': '\u03BC', + 'nabla': '\u2207', + 'nbsp': '\u00A0', + 'ndash': '\u2013', + 'ne': '\u2260', + 'ni': '\u220B', + 'not': '\u00AC', + 'notin': '\u2209', + 'nsub': '\u2284', + 'Ntilde': '\u00D1', + 'ntilde': '\u00F1', + 'Nu': '\u039D', + 'nu': '\u03BD', + 'Oacute': '\u00D3', + 'oacute': '\u00F3', + 'Ocirc': '\u00D4', + 'ocirc': '\u00F4', + 'OElig': '\u0152', + 'oelig': '\u0153', + 'Ograve': '\u00D2', + 'ograve': '\u00F2', + 'oline': '\u203E', + 'Omega': '\u03A9', + 'omega': '\u03C9', + 'Omicron': '\u039F', + 'omicron': '\u03BF', + 'oplus': '\u2295', + 'or': '\u2228', + 'ordf': '\u00AA', + 'ordm': '\u00BA', + 'Oslash': '\u00D8', + 'oslash': '\u00F8', + 'Otilde': '\u00D5', + 'otilde': '\u00F5', + 'otimes': '\u2297', + 'Ouml': '\u00D6', + 'ouml': '\u00F6', + 'para': '\u00B6', + 'permil': '\u2030', + 'perp': '\u22A5', + 'Phi': '\u03A6', + 'phi': '\u03C6', + 'Pi': '\u03A0', + 'pi': '\u03C0', + 'piv': '\u03D6', + 'plusmn': '\u00B1', + 'pound': '\u00A3', + 'prime': '\u2032', + 'Prime': '\u2033', + 'prod': '\u220F', + 'prop': '\u221D', + 'Psi': '\u03A8', + 'psi': '\u03C8', + 'quot': '\u0022', + 'radic': '\u221A', + 'rang': '\u27E9', + 'raquo': '\u00BB', + 'rarr': '\u2192', + 'rArr': '\u21D2', + 'rceil': '\u2309', + 'rdquo': '\u201D', + 'real': '\u211C', + 'reg': '\u00AE', + 'rfloor': '\u230B', + 'Rho': '\u03A1', + 'rho': '\u03C1', + 'rlm': '\u200F', + 'rsaquo': '\u203A', + 'rsquo': '\u2019', + 'sbquo': '\u201A', + 'Scaron': '\u0160', + 'scaron': '\u0161', + 'sdot': '\u22C5', + 'sect': '\u00A7', + 'shy': '\u00AD', + 'Sigma': '\u03A3', + 'sigma': '\u03C3', + 'sigmaf': '\u03C2', + 'sim': '\u223C', + 'spades': '\u2660', + 'sub': '\u2282', + 'sube': '\u2286', + 'sum': '\u2211', + 'sup': '\u2283', + 'sup1': '\u00B9', + 'sup2': '\u00B2', + 'sup3': '\u00B3', + 'supe': '\u2287', + 'szlig': '\u00DF', + 'Tau': '\u03A4', + 'tau': '\u03C4', + 'there4': '\u2234', + 'Theta': '\u0398', + 'theta': '\u03B8', + 'thetasym': '\u03D1', + 'thinsp': '\u2009', + 'THORN': '\u00DE', + 'thorn': '\u00FE', + 'tilde': '\u02DC', + 'times': '\u00D7', + 'trade': '\u2122', + 'Uacute': '\u00DA', + 'uacute': '\u00FA', + 'uarr': '\u2191', + 'uArr': '\u21D1', + 'Ucirc': '\u00DB', + 'ucirc': '\u00FB', + 'Ugrave': '\u00D9', + 'ugrave': '\u00F9', + 'uml': '\u00A8', + 'upsih': '\u03D2', + 'Upsilon': '\u03A5', + 'upsilon': '\u03C5', + 'Uuml': '\u00DC', + 'uuml': '\u00FC', + 'weierp': '\u2118', + 'Xi': '\u039E', + 'xi': '\u03BE', + 'Yacute': '\u00DD', + 'yacute': '\u00FD', + 'yen': '\u00A5', + 'yuml': '\u00FF', + 'Yuml': '\u0178', + 'Zeta': '\u0396', + 'zeta': '\u03B6', + 'zwj': '\u200D', + 'zwnj': '\u200C', +}; diff --git a/packages/upgrade/src/common/compiler_helpers/selector.ts b/packages/upgrade/src/common/compiler_helpers/selector.ts new file mode 100644 index 0000000000..62a9c028a5 --- /dev/null +++ b/packages/upgrade/src/common/compiler_helpers/selector.ts @@ -0,0 +1,368 @@ +/** + * @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 {getHtmlTagDefinition} from './ml_parser/html_tags'; + +const _SELECTOR_REGEXP = new RegExp( + '(\\:not\\()|' + //":not(" + '([-\\w]+)|' + // "tag" + '(?:\\.([-\\w]+))|' + // ".class" + // "-" should appear first in the regexp below as FF31 parses "[.-\w]" as a range + '(?:\\[([-.\\w*]+)(?:=([^\\]]*))?\\])|' + // "[name]", "[name=value]" + '(\\))|' + // ")" + '(\\s*,\\s*)', // "," + 'g'); + +/** + * A css selector contains an element name, + * css classes and attribute/value pairs with the purpose + * of selecting subsets out of them. + */ +export class CssSelector { + element: string = null; + classNames: string[] = []; + attrs: string[] = []; + notSelectors: CssSelector[] = []; + + static parse(selector: string): CssSelector[] { + const results: CssSelector[] = []; + const _addResult = (res: CssSelector[], cssSel: CssSelector) => { + if (cssSel.notSelectors.length > 0 && !cssSel.element && cssSel.classNames.length == 0 && + cssSel.attrs.length == 0) { + cssSel.element = '*'; + } + res.push(cssSel); + }; + let cssSelector = new CssSelector(); + let match: string[]; + let current = cssSelector; + let inNot = false; + _SELECTOR_REGEXP.lastIndex = 0; + while (match = _SELECTOR_REGEXP.exec(selector)) { + if (match[1]) { + if (inNot) { + throw new Error('Nesting :not is not allowed in a selector'); + } + inNot = true; + current = new CssSelector(); + cssSelector.notSelectors.push(current); + } + if (match[2]) { + current.setElement(match[2]); + } + if (match[3]) { + current.addClassName(match[3]); + } + if (match[4]) { + current.addAttribute(match[4], match[5]); + } + if (match[6]) { + inNot = false; + current = cssSelector; + } + if (match[7]) { + if (inNot) { + throw new Error('Multiple selectors in :not are not supported'); + } + _addResult(results, cssSelector); + cssSelector = current = new CssSelector(); + } + } + _addResult(results, cssSelector); + return results; + } + + isElementSelector(): boolean { + return this.hasElementSelector() && this.classNames.length == 0 && this.attrs.length == 0 && + this.notSelectors.length === 0; + } + + hasElementSelector(): boolean { return !!this.element; } + + setElement(element: string = null) { this.element = element; } + + /** Gets a template string for an element that matches the selector. */ + getMatchingElementTemplate(): string { + const tagName = this.element || 'div'; + const classAttr = this.classNames.length > 0 ? ` class="${this.classNames.join(' ')}"` : ''; + + let attrs = ''; + for (let i = 0; i < this.attrs.length; i += 2) { + const attrName = this.attrs[i]; + const attrValue = this.attrs[i + 1] !== '' ? `="${this.attrs[i + 1]}"` : ''; + attrs += ` ${attrName}${attrValue}`; + } + + return getHtmlTagDefinition(tagName).isVoid ? `<${tagName}${classAttr}${attrs}/>` : + `<${tagName}${classAttr}${attrs}>`; + } + + addAttribute(name: string, value: string = '') { + this.attrs.push(name, value && value.toLowerCase() || ''); + } + + addClassName(name: string) { this.classNames.push(name.toLowerCase()); } + + toString(): string { + let res: string = this.element || ''; + if (this.classNames) { + this.classNames.forEach(klass => res += `.${klass}`); + } + if (this.attrs) { + for (let i = 0; i < this.attrs.length; i += 2) { + const name = this.attrs[i]; + const value = this.attrs[i + 1]; + res += `[${name}${value ? '=' + value : ''}]`; + } + } + this.notSelectors.forEach(notSelector => res += `:not(${notSelector})`); + return res; + } +} + +/** + * Reads a list of CssSelectors and allows to calculate which ones + * are contained in a given CssSelector. + */ +export class SelectorMatcher { + static createNotMatcher(notSelectors: CssSelector[]): SelectorMatcher { + const notMatcher = new SelectorMatcher(); + notMatcher.addSelectables(notSelectors, null); + return notMatcher; + } + + private _elementMap = new Map(); + private _elementPartialMap = new Map(); + private _classMap = new Map(); + private _classPartialMap = new Map(); + private _attrValueMap = new Map>(); + private _attrValuePartialMap = new Map>(); + private _listContexts: SelectorListContext[] = []; + + addSelectables(cssSelectors: CssSelector[], callbackCtxt?: any) { + let listContext: SelectorListContext = null; + if (cssSelectors.length > 1) { + listContext = new SelectorListContext(cssSelectors); + this._listContexts.push(listContext); + } + for (let i = 0; i < cssSelectors.length; i++) { + this._addSelectable(cssSelectors[i], callbackCtxt, listContext); + } + } + + /** + * Add an object that can be found later on by calling `match`. + * @param cssSelector A css selector + * @param callbackCtxt An opaque object that will be given to the callback of the `match` function + */ + private _addSelectable( + cssSelector: CssSelector, callbackCtxt: any, listContext: SelectorListContext) { + let matcher: SelectorMatcher = this; + const element = cssSelector.element; + const classNames = cssSelector.classNames; + const attrs = cssSelector.attrs; + const selectable = new SelectorContext(cssSelector, callbackCtxt, listContext); + + if (element) { + const isTerminal = attrs.length === 0 && classNames.length === 0; + if (isTerminal) { + this._addTerminal(matcher._elementMap, element, selectable); + } else { + matcher = this._addPartial(matcher._elementPartialMap, element); + } + } + + if (classNames) { + for (let i = 0; i < classNames.length; i++) { + const isTerminal = attrs.length === 0 && i === classNames.length - 1; + const className = classNames[i]; + if (isTerminal) { + this._addTerminal(matcher._classMap, className, selectable); + } else { + matcher = this._addPartial(matcher._classPartialMap, className); + } + } + } + + if (attrs) { + for (let i = 0; i < attrs.length; i += 2) { + const isTerminal = i === attrs.length - 2; + const name = attrs[i]; + const value = attrs[i + 1]; + if (isTerminal) { + const terminalMap = matcher._attrValueMap; + let terminalValuesMap = terminalMap.get(name); + if (!terminalValuesMap) { + terminalValuesMap = new Map(); + terminalMap.set(name, terminalValuesMap); + } + this._addTerminal(terminalValuesMap, value, selectable); + } else { + const partialMap = matcher._attrValuePartialMap; + let partialValuesMap = partialMap.get(name); + if (!partialValuesMap) { + partialValuesMap = new Map(); + partialMap.set(name, partialValuesMap); + } + matcher = this._addPartial(partialValuesMap, value); + } + } + } + } + + private _addTerminal( + map: Map, name: string, selectable: SelectorContext) { + let terminalList = map.get(name); + if (!terminalList) { + terminalList = []; + map.set(name, terminalList); + } + terminalList.push(selectable); + } + + private _addPartial(map: Map, name: string): SelectorMatcher { + let matcher = map.get(name); + if (!matcher) { + matcher = new SelectorMatcher(); + map.set(name, matcher); + } + return matcher; + } + + /** + * Find the objects that have been added via `addSelectable` + * whose css selector is contained in the given css selector. + * @param cssSelector A css selector + * @param matchedCallback This callback will be called with the object handed into `addSelectable` + * @return boolean true if a match was found + */ + match(cssSelector: CssSelector, matchedCallback: (c: CssSelector, a: any) => void): boolean { + let result = false; + const element = cssSelector.element; + const classNames = cssSelector.classNames; + const attrs = cssSelector.attrs; + + for (let i = 0; i < this._listContexts.length; i++) { + this._listContexts[i].alreadyMatched = false; + } + + result = this._matchTerminal(this._elementMap, element, cssSelector, matchedCallback) || result; + result = this._matchPartial(this._elementPartialMap, element, cssSelector, matchedCallback) || + result; + + if (classNames) { + for (let i = 0; i < classNames.length; i++) { + const className = classNames[i]; + result = + this._matchTerminal(this._classMap, className, cssSelector, matchedCallback) || result; + result = + this._matchPartial(this._classPartialMap, className, cssSelector, matchedCallback) || + result; + } + } + + if (attrs) { + for (let i = 0; i < attrs.length; i += 2) { + const name = attrs[i]; + const value = attrs[i + 1]; + + const terminalValuesMap = this._attrValueMap.get(name); + if (value) { + result = + this._matchTerminal(terminalValuesMap, '', cssSelector, matchedCallback) || result; + } + result = + this._matchTerminal(terminalValuesMap, value, cssSelector, matchedCallback) || result; + + const partialValuesMap = this._attrValuePartialMap.get(name); + if (value) { + result = this._matchPartial(partialValuesMap, '', cssSelector, matchedCallback) || result; + } + result = + this._matchPartial(partialValuesMap, value, cssSelector, matchedCallback) || result; + } + } + return result; + } + + /** @internal */ + _matchTerminal( + map: Map, name: string, cssSelector: CssSelector, + matchedCallback: (c: CssSelector, a: any) => void): boolean { + if (!map || typeof name !== 'string') { + return false; + } + + let selectables: SelectorContext[] = map.get(name) || []; + const starSelectables: SelectorContext[] = map.get('*'); + if (starSelectables) { + selectables = selectables.concat(starSelectables); + } + if (selectables.length === 0) { + return false; + } + let selectable: SelectorContext; + let result = false; + for (let i = 0; i < selectables.length; i++) { + selectable = selectables[i]; + result = selectable.finalize(cssSelector, matchedCallback) || result; + } + return result; + } + + /** @internal */ + _matchPartial( + map: Map, name: string, cssSelector: CssSelector, + matchedCallback: (c: CssSelector, a: any) => void): boolean { + if (!map || typeof name !== 'string') { + return false; + } + + const nestedSelector = map.get(name); + if (!nestedSelector) { + return false; + } + // TODO(perf): get rid of recursion and measure again + // TODO(perf): don't pass the whole selector into the recursion, + // but only the not processed parts + return nestedSelector.match(cssSelector, matchedCallback); + } +} + + +export class SelectorListContext { + alreadyMatched: boolean = false; + + constructor(public selectors: CssSelector[]) {} +} + +// Store context to pass back selector and context when a selector is matched +export class SelectorContext { + notSelectors: CssSelector[]; + + constructor( + public selector: CssSelector, public cbContext: any, + public listContext: SelectorListContext) { + this.notSelectors = selector.notSelectors; + } + + finalize(cssSelector: CssSelector, callback: (c: CssSelector, a: any) => void): boolean { + let result = true; + if (this.notSelectors.length > 0 && (!this.listContext || !this.listContext.alreadyMatched)) { + const notMatcher = SelectorMatcher.createNotMatcher(this.notSelectors); + result = !notMatcher.match(cssSelector, null); + } + if (result && callback && (!this.listContext || !this.listContext.alreadyMatched)) { + if (this.listContext) { + this.listContext.alreadyMatched = true; + } + callback(this.selector, this.cbContext); + } + return result; + } +} diff --git a/packages/upgrade/src/common/component_info.ts b/packages/upgrade/src/common/component_info.ts index 130f8f2589..3419fd178e 100644 --- a/packages/upgrade/src/common/component_info.ts +++ b/packages/upgrade/src/common/component_info.ts @@ -12,6 +12,7 @@ export interface ComponentInfo { component: Type; inputs?: string[]; outputs?: string[]; + selectors?: string[]; } /** diff --git a/packages/upgrade/src/common/downgrade_component.ts b/packages/upgrade/src/common/downgrade_component.ts index 41899f7542..a25609be84 100644 --- a/packages/upgrade/src/common/downgrade_component.ts +++ b/packages/upgrade/src/common/downgrade_component.ts @@ -11,6 +11,7 @@ import {ComponentFactory, ComponentFactoryResolver, Injector, Type} from '@angul import * as angular from './angular1'; import {$COMPILE, $INJECTOR, $PARSE, INJECTOR_KEY, REQUIRE_INJECTOR, REQUIRE_NG_MODEL} from './constants'; import {DowngradeComponentAdapter} from './downgrade_component_adapter'; +import {NgContentSelectorHelper} from './ng_content_selector_helper'; import {controllerKey, getComponentName} from './util'; let downgradeCount = 0; @@ -44,6 +45,7 @@ let downgradeCount = 0; * We must do the following: * * specify the Angular component class that is to be downgraded * * specify all inputs and outputs that the AngularJS component expects + * * specify the selectors used in any `ng-content` elements in the component's template * * @description * @@ -53,19 +55,28 @@ let downgradeCount = 0; * The parameter contains information about the Component that is being downgraded: * * * `component: Type`: The type of the Component that will be downgraded - * * `inputs: string[]`: A collection of strings that specify what inputs the component accepts. - * * `outputs: string[]`: A collection of strings that specify what outputs the component emits. + * * `inputs: string[]`: A collection of strings that specify what inputs the component accepts + * * `outputs: string[]`: A collection of strings that specify what outputs the component emits + * * `selectors: string[]`: A collection of strings that specify what selectors are expected on + * `ng-content` elements in the template to enable content projection (a.k.a. transclusion in + * AngularJS) * * The `inputs` and `outputs` are strings that map the names of properties to camelCased * attribute names. They are of the form `"prop: attr"`; or simply `"propAndAttr" where the * property and attribute have the same identifier. * + * The `selectors` are the values of the `select` attribute of each of the `ng-content` elements + * that appear in the downgraded component's template. + * These selectors must be provided in the order that they appear in the template as they are + * mapped by index to the projected nodes. + * * @experimental */ export function downgradeComponent(info: /* ComponentInfo */ { component: Type; inputs?: string[]; outputs?: string[]; + selectors?: string[] }): any /* angular.IInjectable */ { const idPrefix = `NG2_UPGRADE_${downgradeCount++}_`; let idCount = 0; diff --git a/packages/upgrade/src/common/downgrade_component_adapter.ts b/packages/upgrade/src/common/downgrade_component_adapter.ts index fa536a47e2..b926c13474 100644 --- a/packages/upgrade/src/common/downgrade_component_adapter.ts +++ b/packages/upgrade/src/common/downgrade_component_adapter.ts @@ -9,10 +9,12 @@ import {ChangeDetectorRef, ComponentFactory, ComponentRef, EventEmitter, Injector, OnChanges, ReflectiveInjector, SimpleChange, SimpleChanges, Type} from '@angular/core'; import * as angular from './angular1'; +import {createElementCssSelector} from './compiler_helpers/createElementCssSelector'; +import {CssSelector, SelectorMatcher} from './compiler_helpers/selector'; import {ComponentInfo, PropertyBinding} from './component_info'; import {$SCOPE} from './constants'; -import {ContentProjectionHelper} from './content_projection_helper'; -import {getComponentName, hookupNgModel} from './util'; +import {NgContentSelectorHelper} from './ng_content_selector_helper'; +import {getAttributesAsArray, getComponentName, hookupNgModel} from './util'; const INITIAL_VALUE = { __UNINITIALIZED__: true @@ -38,12 +40,7 @@ export class DowngradeComponentAdapter { compileContents(): Node[][] { const compiledProjectableNodes: Node[][] = []; - - // The projected content has to be grouped, before it is compiled. - const projectionHelper: ContentProjectionHelper = - this.parentInjector.get(ContentProjectionHelper); - const projectableNodes: Node[][] = projectionHelper.groupProjectableNodes( - this.$injector, this.info.component, this.element.contents()); + const projectableNodes: Node[][] = this.groupProjectableNodes(); const linkFns = projectableNodes.map(nodes => this.$compile(nodes)); this.element.empty(); @@ -186,4 +183,56 @@ export class DowngradeComponentAdapter { this.component[prop] = currValue; } + + groupProjectableNodes() { + const ngContentSelectorHelper = + this.parentInjector.get(NgContentSelectorHelper) as NgContentSelectorHelper; + const ngContentSelectors = ngContentSelectorHelper.getNgContentSelectors(this.info); + + if (!ngContentSelectors) { + throw new Error('Expecting ngContentSelectors for: ' + getComponentName(this.info.component)); + } + + return this._groupNodesBySelector(ngContentSelectors, this.element.contents()); + } + + /** + * Group a set of DOM nodes into `ngContent` groups, based on the given content selectors. + */ + private _groupNodesBySelector(ngContentSelectors: string[], nodes: Node[]): Node[][] { + const projectableNodes: Node[][] = []; + let matcher = new SelectorMatcher(); + let wildcardNgContentIndex: number; + + for (let i = 0, ii = ngContentSelectors.length; i < ii; ++i) { + projectableNodes[i] = []; + + const selector = ngContentSelectors[i]; + if (selector === '*') { + wildcardNgContentIndex = i; + } else { + matcher.addSelectables(CssSelector.parse(selector), i); + } + } + + for (let j = 0, jj = nodes.length; j < jj; ++j) { + const ngContentIndices: number[] = []; + const node = nodes[j]; + const selector = + createElementCssSelector(node.nodeName.toLowerCase(), getAttributesAsArray(node)); + + matcher.match(selector, (_, index) => ngContentIndices.push(index)); + ngContentIndices.sort(); + + if (wildcardNgContentIndex !== undefined) { + ngContentIndices.push(wildcardNgContentIndex); + } + + if (ngContentIndices.length) { + projectableNodes[ngContentIndices[0]].push(node); + } + } + + return projectableNodes; + } } diff --git a/packages/upgrade/src/common/ng_content_selector_helper.ts b/packages/upgrade/src/common/ng_content_selector_helper.ts new file mode 100644 index 0000000000..62ea7aa564 --- /dev/null +++ b/packages/upgrade/src/common/ng_content_selector_helper.ts @@ -0,0 +1,24 @@ +/** + * @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 {ComponentInfo} from './component_info'; + +/** + * This class gives an extension point between the static and dynamic versions + * of ngUpgrade: + * * In the static version (this one) we must specify them manually as part of + * the call to `downgradeComponent(...)`. + * * In the dynamic version (`DynamicNgContentSelectorHelper`) we are able to + * ask the compiler for the selectors of a component. + */ +export class NgContentSelectorHelper { + getNgContentSelectors(info: ComponentInfo): string[] { + // if no selectors are passed then default to a single "wildcard" selector + return info.selectors || ['*']; + } +} diff --git a/packages/upgrade/src/dynamic/ng_content_selector_helper.ts b/packages/upgrade/src/dynamic/ng_content_selector_helper.ts new file mode 100644 index 0000000000..085d101afe --- /dev/null +++ b/packages/upgrade/src/dynamic/ng_content_selector_helper.ts @@ -0,0 +1,24 @@ +/** + * @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 {Compiler, Injectable} from '@angular/core'; + +import {ComponentInfo} from '../common/component_info'; +import {NgContentSelectorHelper} from '../common/ng_content_selector_helper'; + + +/** + * See `NgContentSelectorHelper` for more information about this class. + */ +@Injectable() +export class DynamicNgContentSelectorHelper extends NgContentSelectorHelper { + constructor(private compiler: Compiler) { super(); } + getNgContentSelectors(info: ComponentInfo): string[] { + return this.compiler.getNgContentSelectors(info.component); + } +} diff --git a/packages/upgrade/src/dynamic/upgrade_adapter.ts b/packages/upgrade/src/dynamic/upgrade_adapter.ts index 106ac05516..14efd6870d 100644 --- a/packages/upgrade/src/dynamic/upgrade_adapter.ts +++ b/packages/upgrade/src/dynamic/upgrade_adapter.ts @@ -13,12 +13,12 @@ import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; import * as angular from '../common/angular1'; import {ComponentInfo} from '../common/component_info'; import {$$TESTABILITY, $COMPILE, $INJECTOR, $ROOT_SCOPE, COMPILER_KEY, INJECTOR_KEY, NG_ZONE_KEY} from '../common/constants'; -import {ContentProjectionHelper} from '../common/content_projection_helper'; import {downgradeComponent} from '../common/downgrade_component'; import {downgradeInjectable} from '../common/downgrade_injectable'; +import {NgContentSelectorHelper} from '../common/ng_content_selector_helper'; import {Deferred, controllerKey, onError} from '../common/util'; -import {DynamicContentProjectionHelper} from './content_projection_helper'; +import {DynamicNgContentSelectorHelper} from './ng_content_selector_helper'; import {UpgradeNg1ComponentAdapterBuilder} from './upgrade_ng1_adapter'; let upgradeCount: number = 0; @@ -561,7 +561,7 @@ export class UpgradeAdapter { providers: [ {provide: $INJECTOR, useFactory: () => ng1Injector}, {provide: $COMPILE, useFactory: () => ng1Injector.get($COMPILE)}, - {provide: ContentProjectionHelper, useClass: DynamicContentProjectionHelper}, + {provide: NgContentSelectorHelper, useClass: DynamicNgContentSelectorHelper}, this.upgradedProviders ], imports: [this.ng2AppModule], diff --git a/packages/upgrade/src/static/upgrade_module.ts b/packages/upgrade/src/static/upgrade_module.ts index 5a9c128629..5c32a814c9 100644 --- a/packages/upgrade/src/static/upgrade_module.ts +++ b/packages/upgrade/src/static/upgrade_module.ts @@ -10,7 +10,7 @@ import {Injector, NgModule, NgZone, Testability} from '@angular/core'; import * as angular from '../common/angular1'; import {$$TESTABILITY, $DELEGATE, $INJECTOR, $PROVIDE, $ROOT_SCOPE, INJECTOR_KEY, UPGRADE_MODULE_NAME} from '../common/constants'; -import {ContentProjectionHelper} from '../common/content_projection_helper'; +import {NgContentSelectorHelper} from '../common/ng_content_selector_helper'; import {controllerKey} from '../common/util'; import {angular1Providers, setTempInjectorRef} from './angular1_providers'; @@ -130,7 +130,7 @@ import {angular1Providers, setTempInjectorRef} from './angular1_providers'; * * @experimental */ -@NgModule({providers: [angular1Providers, ContentProjectionHelper]}) +@NgModule({providers: [angular1Providers, NgContentSelectorHelper]}) export class UpgradeModule { /** * The AngularJS `$injector` for the upgrade application. diff --git a/packages/upgrade/test/common/downgrade_component_adapter_spec.ts b/packages/upgrade/test/common/downgrade_component_adapter_spec.ts new file mode 100644 index 0000000000..c92b6f601b --- /dev/null +++ b/packages/upgrade/test/common/downgrade_component_adapter_spec.ts @@ -0,0 +1,96 @@ +/** + * @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 * as angular from '@angular/upgrade/src/common/angular1'; +import {DowngradeComponentAdapter} from '@angular/upgrade/src/common/downgrade_component_adapter'; +import {NgContentSelectorHelper} from '@angular/upgrade/src/common/ng_content_selector_helper'; +import {nodes} from './test_helpers'; + + +export function main() { + describe('DowngradeComponentAdapter', () => { + describe('groupNodesBySelector', () => { + function createAdapter(selectors: string[], contentNodes: Node[]): DowngradeComponentAdapter { + const selectorHelper = new NgContentSelectorHelper(); + const fakeInjector = {get: function() { return selectorHelper; }}; + const fakeScope = { $new: function() {} } as any; + const element = angular.element('
'); + element.append(contentNodes); + return new DowngradeComponentAdapter( + 'id', {component: null, selectors}, element, null, fakeScope, null, fakeInjector, null, + null, null, null); + } + + it('should return an array of node collections for each selector', () => { + const contentNodes = nodes( + '
div-1 content
' + + '' + + '' + + 'span content' + + '
div-2 content
'); + + const selectors = ['input[type=date]', 'span', '.x']; + const adapter = createAdapter(selectors, contentNodes); + const projectableNodes = adapter.groupProjectableNodes(); + + expect(projectableNodes[0]).toEqual(nodes('')); + expect(projectableNodes[1]).toEqual(nodes('span content')); + expect(projectableNodes[2]) + .toEqual(nodes( + '
div-1 content
' + + '
div-2 content
')); + }); + + it('should collect up unmatched nodes for the wildcard selector', () => { + const contentNodes = nodes( + '
div-1 content
' + + '' + + '' + + 'span content' + + '
div-2 content
'); + + const selectors = ['.x', '*', 'input[type=date]']; + const adapter = createAdapter(selectors, contentNodes); + const projectableNodes = adapter.groupProjectableNodes(); + + expect(projectableNodes[0]) + .toEqual(nodes( + '
div-1 content
' + + '
div-2 content
')); + expect(projectableNodes[1]) + .toEqual(nodes( + '' + + 'span content')); + expect(projectableNodes[2]).toEqual(nodes('')); + }); + + it('should return an array of empty arrays if there are no nodes passed in', () => { + const selectors = ['.x', '*', 'input[type=date]']; + const adapter = createAdapter(selectors, []); + const projectableNodes = adapter.groupProjectableNodes(); + expect(projectableNodes).toEqual([[], [], []]); + }); + + it('should return an empty array for each selector that does not match', () => { + const contentNodes = nodes( + '
div-1 content
' + + '' + + '' + + 'span content' + + '
div-2 content
'); + + const adapter1 = createAdapter([], contentNodes); + const projectableNodes = adapter1.groupProjectableNodes(); + expect(projectableNodes).toEqual([]); + + const adapter2 = createAdapter(['.not-there'], contentNodes); + const noMatchSelectorNodes = adapter2.groupProjectableNodes(); + expect(noMatchSelectorNodes).toEqual([[]]); + }); + }); + }); +} diff --git a/packages/upgrade/test/common/test_helpers.ts b/packages/upgrade/test/common/test_helpers.ts index ae6e8c1e78..6847a79db0 100644 --- a/packages/upgrade/test/common/test_helpers.ts +++ b/packages/upgrade/test/common/test_helpers.ts @@ -23,3 +23,9 @@ export function html(html: string): Element { export function multiTrim(text: string): string { return text.replace(/\n/g, '').replace(/\s\s+/g, ' ').trim(); } + +export function nodes(html: string) { + const div = document.createElement('div'); + div.innerHTML = html.trim(); + return Array.prototype.slice.call(div.childNodes); +} diff --git a/packages/upgrade/test/dynamic/test_helpers.ts b/packages/upgrade/test/dynamic/test_helpers.ts index a1ac576d64..d1676d7569 100644 --- a/packages/upgrade/test/dynamic/test_helpers.ts +++ b/packages/upgrade/test/dynamic/test_helpers.ts @@ -7,9 +7,3 @@ */ export * from '../common/test_helpers'; - -export function nodes(html: string) { - const div = document.createElement('div'); - div.innerHTML = html.trim(); - return Array.prototype.slice.call(div.childNodes); -} diff --git a/packages/upgrade/test/static/integration/content_projection_spec.ts b/packages/upgrade/test/static/integration/content_projection_spec.ts index 57ec145902..d4e636db6f 100644 --- a/packages/upgrade/test/static/integration/content_projection_spec.ts +++ b/packages/upgrade/test/static/integration/content_projection_spec.ts @@ -140,5 +140,39 @@ export function main() { expect(document.body.textContent).toEqual('ng1(ng2(ng1(ng2-transclude)))'); }); })); + + it('should support multi-slot projection', async(() => { + + @Component({ + selector: 'ng2', + template: '2a()' + + '2b()' + }) + class Ng2Component { + constructor() {} + } + + @NgModule({ + declarations: [Ng2Component], + entryComponents: [Ng2Component], + imports: [BrowserModule, UpgradeModule] + }) + class Ng2Module { + ngDoBootstrap() {} + } + + const ng1Module = angular.module('ng1', []).directive( + 'ng2', downgradeComponent({component: Ng2Component, selectors: ['.ng1a', '.ng1b']})); + + // The ng-if on one of the projected children is here to make sure + // the correct slot is targeted even with structural directives in play. + const element = html( + '
1a
1b
'); + + bootstrap(platformBrowserDynamic(), Ng2Module, element, ng1Module).then((upgrade) => { + expect(document.body.textContent).toEqual('2a(1a)2b(1b)'); + }); + })); }); } diff --git a/tools/public_api_guard/upgrade/typings/static/index.d.ts b/tools/public_api_guard/upgrade/typings/static/index.d.ts index ef0dbd39b3..6398773b84 100644 --- a/tools/public_api_guard/upgrade/typings/static/index.d.ts +++ b/tools/public_api_guard/upgrade/typings/static/index.d.ts @@ -3,6 +3,7 @@ export declare function downgradeComponent(info: { component: Type; inputs?: string[]; outputs?: string[]; + selectors?: string[]; }): any; /** @experimental */