diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel index fd2f5891ecf7d..fe2445ce13955 100644 --- a/packages/core/schematics/BUILD.bazel +++ b/packages/core/schematics/BUILD.bazel @@ -12,6 +12,7 @@ pkg_npm( "collection.json", "migrations.json", "package.json", + "//packages/core/schematics/ng-generate/control-flow-migration:static_files", "//packages/core/schematics/ng-generate/standalone-migration:static_files", ], validate = False, @@ -19,6 +20,7 @@ pkg_npm( deps = [ "//packages/core/schematics/migrations/block-template-entities:bundle", "//packages/core/schematics/migrations/compiler-options:bundle", + "//packages/core/schematics/ng-generate/control-flow-migration:bundle", "//packages/core/schematics/ng-generate/standalone-migration:bundle", ], ) diff --git a/packages/core/schematics/collection.json b/packages/core/schematics/collection.json index c5c9284b2ba13..b5dc2135497e5 100644 --- a/packages/core/schematics/collection.json +++ b/packages/core/schematics/collection.json @@ -4,7 +4,17 @@ "description": "Converts the entire application or a part of it to standalone", "factory": "./ng-generate/standalone-migration/bundle", "schema": "./ng-generate/standalone-migration/schema.json", - "aliases": ["standalone"] + "aliases": [ + "standalone" + ] + }, + "control-flow-migration": { + "description": "Converts the entire application to block control flow syntax", + "factory": "./ng-generate/control-flow-migration/bundle", + "schema": "./ng-generate/control-flow-migration/schema.json", + "aliases": [ + "control-flow" + ] } } -} +} \ No newline at end of file diff --git a/packages/core/schematics/ng-generate/control-flow-migration/BUILD.bazel b/packages/core/schematics/ng-generate/control-flow-migration/BUILD.bazel new file mode 100644 index 0000000000000..b09bff71fc340 --- /dev/null +++ b/packages/core/schematics/ng-generate/control-flow-migration/BUILD.bazel @@ -0,0 +1,39 @@ +load("//tools:defaults.bzl", "esbuild", "ts_library") + +package( + default_visibility = [ + "//packages/core/schematics:__pkg__", + "//packages/core/schematics/migrations/google3:__pkg__", + "//packages/core/schematics/test:__pkg__", + ], +) + +filegroup( + name = "static_files", + srcs = ["schema.json"], +) + +ts_library( + name = "control-flow-migration", + srcs = glob(["**/*.ts"]), + tsconfig = "//packages/core/schematics:tsconfig.json", + deps = [ + "//packages/compiler", + "//packages/core/schematics/utils", + "@npm//@angular-devkit/schematics", + "@npm//@types/node", + "@npm//typescript", + ], +) + +esbuild( + name = "bundle", + entry_point = ":index.ts", + external = [ + "@angular-devkit/*", + "typescript", + ], + format = "cjs", + platform = "node", + deps = [":control-flow-migration"], +) diff --git a/packages/core/schematics/ng-generate/control-flow-migration/README.md b/packages/core/schematics/ng-generate/control-flow-migration/README.md new file mode 100644 index 0000000000000..4f48f6ee140d1 --- /dev/null +++ b/packages/core/schematics/ng-generate/control-flow-migration/README.md @@ -0,0 +1,33 @@ +## Control Flow Syntax migration + +Angular v17 introduces a new control flow syntax. This migration replaces the +existing usages of `*ngIf`, `*ngFor`, and `*ngSwitch` to their equivalent block +syntax. Existing ng-templates are preserved in case they are used elsewhere in +the template. + +NOTE: This is a developer preview migration + +#### Before +```ts +import {Component} from '@angular/core'; + +@Component({ + template: `
Content here
` +}) +export class MyComp { + show = false; +} +``` + + +#### After +```ts +import {Component} from '@angular/core'; + +@Component({ + template: `
@if (show) {Content here}
` +}) +export class MyComp { + show = false +} +``` diff --git a/packages/core/schematics/ng-generate/control-flow-migration/index.ts b/packages/core/schematics/ng-generate/control-flow-migration/index.ts new file mode 100644 index 0000000000000..52133a6fa91b1 --- /dev/null +++ b/packages/core/schematics/ng-generate/control-flow-migration/index.ts @@ -0,0 +1,63 @@ +/** + * @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 {Rule, SchematicsException, Tree} from '@angular-devkit/schematics'; +import {relative} from 'path'; + +import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths'; +import {canMigrateFile, createMigrationProgram} from '../../utils/typescript/compiler_host'; + +import {analyze, AnalyzedFile, migrateTemplate} from './util'; + +export default function(): Rule { + return async (tree: Tree) => { + const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree); + const basePath = process.cwd(); + const allPaths = [...buildPaths, ...testPaths]; + + if (!allPaths.length) { + throw new SchematicsException( + 'Could not find any tsconfig file. Cannot run the control flow migration.'); + } + + for (const tsconfigPath of allPaths) { + runControlFlowMigration(tree, tsconfigPath, basePath); + } + }; +} + +function runControlFlowMigration(tree: Tree, tsconfigPath: string, basePath: string) { + const program = createMigrationProgram(tree, tsconfigPath, basePath); + const sourceFiles = + program.getSourceFiles().filter(sourceFile => canMigrateFile(basePath, sourceFile, program)); + const analysis = new Map(); + + for (const sourceFile of sourceFiles) { + analyze(sourceFile, analysis); + } + + for (const [path, file] of analysis) { + const ranges = file.getSortedRanges(); + const relativePath = relative(basePath, path); + const content = tree.readText(relativePath); + const update = tree.beginUpdate(relativePath); + + for (const [start, end] of ranges) { + const template = content.slice(start, end); + const length = (end ?? content.length) - start; + const migrated = migrateTemplate(template); + + if (migrated !== null) { + update.remove(start, length); + update.insertLeft(start, migrated); + } + } + + tree.commitUpdate(update); + } +} diff --git a/packages/core/schematics/ng-generate/control-flow-migration/schema.json b/packages/core/schematics/ng-generate/control-flow-migration/schema.json new file mode 100644 index 0000000000000..dc41201e2e91d --- /dev/null +++ b/packages/core/schematics/ng-generate/control-flow-migration/schema.json @@ -0,0 +1,7 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "AngularControlFlowMigration", + "title": "Angular Control Flow Migration Schema", + "type": "object", + "properties": {} +} \ No newline at end of file diff --git a/packages/core/schematics/ng-generate/control-flow-migration/util.ts b/packages/core/schematics/ng-generate/control-flow-migration/util.ts new file mode 100644 index 0000000000000..2be7b0bdd46c8 --- /dev/null +++ b/packages/core/schematics/ng-generate/control-flow-migration/util.ts @@ -0,0 +1,524 @@ +/** + * @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 {Attribute, Element, HtmlParser, Node, ParseTreeResult, RecursiveVisitor, visitAll} from '@angular/compiler'; +import {dirname, join} from 'path'; +import ts from 'typescript'; + +const ngif = '*ngIf'; +const ngfor = '*ngFor'; +const ngswitch = '[ngSwitch]'; +const attributesToMigrate = [ + ngif, + ngfor, + ngswitch, +]; + +const casesToMigrate = [ + '*ngSwitchCase', + '*ngSwitchDefault', +]; + +/** + * Represents a range of text within a file. Omitting the end + * means that it's until the end of the file. + */ +type Range = [start: number, end?: number]; + +/** + * Represents an element with a migratable attribute + */ +class ElementToMigrate { + el: Element; + attr: Attribute; + nestCount = 0; + + constructor(el: Element, attr: Attribute) { + this.el = el; + this.attr = attr; + } + + getCondition(targetStr: string): string { + const targetLocation = this.attr.value.indexOf(targetStr); + return this.attr.value.slice(0, targetLocation); + } + + getTemplateName(targetStr: string, secondStr?: string): string { + const targetLocation = this.attr.value.indexOf(targetStr); + if (secondStr) { + const secondTargetLocation = this.attr.value.indexOf(secondStr); + return this.attr.value.slice(targetLocation + targetStr.length, secondTargetLocation).trim(); + } + return this.attr.value.slice(targetLocation + targetStr.length).trim(); + } + + start(offset: number): number { + return this.el.sourceSpan?.start.offset - this.nestCount - offset; + } + + end(offset: number): number { + return this.el.sourceSpan?.end.offset - this.nestCount - offset; + } + + length(): number { + return this.el.sourceSpan.end.offset - this.el.sourceSpan.start.offset; + } + + openLength(): number { + return this.el.children[0].sourceSpan.start.offset - this.el.sourceSpan.start.offset; + } + + closeLength(): number { + return this.el.sourceSpan.end.offset - this.el.children[0].sourceSpan.end.offset; + } + + preOffset(newOffset: number): number { + return newOffset - this.openLength() + 1; + } + + postOffset(newOffset: number): number { + return newOffset - this.closeLength(); + } +} + +class Template { + el: Element; + count: number = 0; + contents: string = ''; + children: string = ''; + + constructor(el: Element) { + this.el = el; + } + + generateContents(tmpl: string) { + this.contents = tmpl.slice(this.el.sourceSpan.start.offset, this.el.sourceSpan.end.offset + 1); + this.children = tmpl.slice( + this.el.children[0].sourceSpan.start.offset, + this.el.children[this.el.children.length - 1].sourceSpan.end.offset); + } +} + +/** Represents a file that was analyzed by the migration. */ +export class AnalyzedFile { + private ranges: Range[] = []; + + /** Returns the ranges in the order in which they should be migrated. */ + getSortedRanges(): Range[] { + return this.ranges.slice().sort(([aStart], [bStart]) => bStart - aStart); + } + + /** + * Adds a text range to an `AnalyzedFile`. + * @param path Path of the file. + * @param analyzedFiles Map keeping track of all the analyzed files. + * @param range Range to be added. + */ + static addRange(path: string, analyzedFiles: Map, range: Range): void { + let analysis = analyzedFiles.get(path); + + if (!analysis) { + analysis = new AnalyzedFile(); + analyzedFiles.set(path, analysis); + } + + const duplicate = + analysis.ranges.find(current => current[0] === range[0] && current[1] === range[1]); + + if (!duplicate) { + analysis.ranges.push(range); + } + } +} + +/** + * Analyzes a source file to find file that need to be migrated and the text ranges within them. + * @param sourceFile File to be analyzed. + * @param analyzedFiles Map in which to store the results. + */ +export function analyze(sourceFile: ts.SourceFile, analyzedFiles: Map) { + for (const node of sourceFile.statements) { + if (!ts.isClassDeclaration(node)) { + continue; + } + + // Note: we have a utility to resolve the Angular decorators from a class declaration already. + // We don't use it here, because it requires access to the type checker which makes it more + // time-consuming to run internally. + const decorator = ts.getDecorators(node)?.find(dec => { + return ts.isCallExpression(dec.expression) && ts.isIdentifier(dec.expression.expression) && + dec.expression.expression.text === 'Component'; + }) as (ts.Decorator & {expression: ts.CallExpression}) | + undefined; + + const metadata = decorator && decorator.expression.arguments.length > 0 && + ts.isObjectLiteralExpression(decorator.expression.arguments[0]) ? + decorator.expression.arguments[0] : + null; + + if (!metadata) { + continue; + } + + for (const prop of metadata.properties) { + // All the properties we care about should have static + // names and be initialized to a static string. + if (!ts.isPropertyAssignment(prop) || !ts.isStringLiteralLike(prop.initializer) || + (!ts.isIdentifier(prop.name) && !ts.isStringLiteralLike(prop.name))) { + continue; + } + + switch (prop.name.text) { + case 'template': + // +1/-1 to exclude the opening/closing characters from the range. + AnalyzedFile.addRange( + sourceFile.fileName, analyzedFiles, + [prop.initializer.getStart() + 1, prop.initializer.getEnd() - 1]); + break; + + case 'templateUrl': + // Leave the end as undefined which means that the range is until the end of the file. + const path = join(dirname(sourceFile.fileName), prop.initializer.text); + AnalyzedFile.addRange(path, analyzedFiles, [0]); + break; + } + } + } +} + +/** + * returns the level deep a migratable element is nested + */ +function getNestedCount(etm: ElementToMigrate, aggregator: number[]) { + if (aggregator.length === 0) { + return 0; + } + if (etm.el.sourceSpan.start.offset < aggregator[aggregator.length - 1] && + etm.el.sourceSpan.end.offset !== aggregator[aggregator.length - 1]) { + // element is nested + aggregator.push(etm.el.sourceSpan.end.offset); + return aggregator.length - 1; + } else { + // not nested + aggregator.pop()!; + return getNestedCount(etm, aggregator); + } +} + +/** + * Replaces structural directive control flow instances with block control flow equivalents. + * Returns null if the migration failed (e.g. there was a syntax error). + */ +export function migrateTemplate(template: string): string|null { + let parsed: ParseTreeResult; + try { + // Note: we use the HtmlParser here, instead of the `parseTemplate` function, because the + // latter returns an Ivy AST, not an HTML AST. The HTML AST has the advantage of preserving + // interpolated text as text nodes containing a mixture of interpolation tokens and text tokens, + // rather than turning them into `BoundText` nodes like the Ivy AST does. This allows us to + // easily get the text-only ranges without having to reconstruct the original text. + parsed = new HtmlParser().parse(template, '', { + // Allows for ICUs to be parsed. + tokenizeExpansionForms: true, + // Explicitly disable blocks so that their characters are treated as plain text. + tokenizeBlocks: false, + }); + + // Don't migrate invalid templates. + if (parsed.errors && parsed.errors.length > 0) { + return null; + } + } catch { + return null; + } + + let result = template; + const visitor = new ElementCollector(); + visitAll(visitor, parsed.rootNodes); + + // count usages of each ng-template + for (let [key, tmpl] of visitor.templates) { + const regex = new RegExp(`\\W${key.slice(1)}\\W`, 'gm'); + const matches = template.match(regex); + tmpl.count = matches?.length ?? 0; + tmpl.generateContents(template); + } + + // start from top of template + // loop through each element + let prevElEnd = visitor.elements[0]?.el.sourceSpan.end.offset ?? result.length - 1; + let nestedQueue: number[] = [prevElEnd]; + for (let i = 1; i < visitor.elements.length; i++) { + let currEl = visitor.elements[i]; + currEl.nestCount = getNestedCount(currEl, nestedQueue); + } + + // this tracks the character shift from different lengths of blocks from + // the prior directives so as to adjust for nested block replacement during + // migration. Each block calculates length differences and passes that offset + // to the next migrating block to adjust character offsets properly. + let offset = 0; + + for (const el of visitor.elements) { + // these are all migratable nodes + + if (el.attr.name === ngif) { + let ifResult = migrateNgIf(el, visitor.templates, result, offset); + result = ifResult.tmpl; + offset = ifResult.offset; + } else if (el.attr.name === ngfor) { + let forResult = migrateNgFor(el, result, offset); + result = forResult.tmpl; + offset = forResult.offset; + } else if (el.attr.name === ngswitch) { + let switchResult = migrateNgSwitch(el, result, offset); + result = switchResult.tmpl; + offset = switchResult.offset; + } + } + + for (const [_, t] of visitor.templates) { + if (t.count === 2) { + result = result.replace(t.contents, ''); + } + } + + return result; +} + +function migrateNgFor( + etm: ElementToMigrate, tmpl: string, offset: number): {tmpl: string, offset: number} { + const aliasRegexp = /=\s+(count|index|first|last|even|odd)/gm; + const aliases = []; + + const parts = etm.attr.value.split(';'); + + // first portion should always be the loop definition prefixed with `let` + const condition = parts[0].replace('let ', ''); + const loopVar = condition.split(' of ')[0]; + let trackBy = loopVar; + for (let i = 1; i < parts.length; i++) { + const part = parts[i].trim(); + + if (part.startsWith('trackBy:')) { + // build trackby value + const trackByFn = part.replace('trackBy:', '').trim(); + trackBy = `${trackByFn}($index, ${loopVar})`; + } + // aliases + if (part.match(aliasRegexp)) { + const aliasParts = part.split('='); + aliases.push(` ${aliasParts[0].trim()} = $${aliasParts[1].trim()}`); + } + } + + const aliasStr = (aliases.length > 0) ? `;${aliases.join(';')}` : ''; + + const startBlock = `@for (${condition}; track ${trackBy}${aliasStr}) {`; + + const mainBlock = getMainBlock(etm, tmpl, offset); + const forBlock = startBlock + mainBlock + '}'; + + const updatedTmpl = tmpl.slice(0, etm.start(offset)) + forBlock + tmpl.slice(etm.end(offset)); + + offset = offset + etm.length() - forBlock.length; + + return {tmpl: updatedTmpl, offset}; +} + +function migrateNgIf( + etm: ElementToMigrate, ngTemplates: Map, tmpl: string, + offset: number): {tmpl: string, offset: number} { + const matchThen = etm.attr.value.match(/;\s+then/gm); + const matchElse = etm.attr.value.match(/;\s+else/gm); + + if (matchThen && matchThen.length > 0) { + return buildIfThenElseBlock(etm, ngTemplates, tmpl, matchThen[0], matchElse![0], offset); + } else if (matchElse && matchElse.length > 0) { + // just else + return buildIfElseBlock(etm, ngTemplates, tmpl, matchElse[0], offset); + } + + return buildIfBlock(etm, tmpl, offset); +} + +function buildIfBlock( + etm: ElementToMigrate, tmpl: string, offset: number): {tmpl: string, offset: number} { + const condition = etm.attr.value; + const startBlock = `@if (${condition}) {`; + + const ifBlock = startBlock + getMainBlock(etm, tmpl, offset) + `}`; + const updatedTmpl = tmpl.slice(0, etm.start(offset)) + ifBlock + tmpl.slice(etm.end(offset)); + + offset = offset + etm.length() - ifBlock.length; + + return {tmpl: updatedTmpl, offset}; +} + +function buildIfElseBlock( + etm: ElementToMigrate, ngTemplates: Map, tmpl: string, elseString: string, + offset: number): {tmpl: string, offset: number} { + const condition = etm.getCondition(elseString); + + const elseTmpl = ngTemplates.get(`#${etm.getTemplateName(elseString)}`)!; + const startBlock = `@if (${condition}) {`; + const mainBlock = getMainBlock(etm, tmpl, offset); + const elseBlock = `} @else {`; + const postBlock = elseBlock + elseTmpl.children + '}'; + const ifElseBlock = startBlock + mainBlock + postBlock; + + let tmplStart = tmpl.slice(0, etm.start(offset)); + let tmplEnd = tmpl.slice(etm.end(offset)); + const updatedTmpl = tmplStart + ifElseBlock + tmplEnd; + + offset = offset + etm.preOffset(startBlock.length) + + etm.postOffset(mainBlock.length + postBlock.length); + + return {tmpl: updatedTmpl, offset}; +} + +function buildIfThenElseBlock( + etm: ElementToMigrate, ngTemplates: Map, tmpl: string, thenString: string, + elseString: string, offset: number): {tmpl: string, offset: number} { + const condition = etm.getCondition(thenString); + + const startBlock = `@if (${condition}) {`; + const elseBlock = `} @else {`; + + const thenTmpl = ngTemplates.get(`#${etm.getTemplateName(thenString, elseString)}`)!; + const elseTmpl = ngTemplates.get(`#${etm.getTemplateName(elseString)}`)!; + + const postBlock = thenTmpl.children + elseBlock + elseTmpl.children + '}'; + const ifThenElseBlock = startBlock + postBlock; + + let tmplStart = tmpl.slice(0, etm.start(offset)); + let tmplEnd = tmpl.slice(etm.end(offset)); + + const updatedTmpl = tmplStart + ifThenElseBlock + tmplEnd; + + offset = offset + etm.preOffset(startBlock.length) + etm.postOffset(postBlock.length); + + return {tmpl: updatedTmpl, offset}; +} + +function getMainBlock(etm: ElementToMigrate, tmpl: string, offset: number) { + if (etm.el.name === 'ng-container' && etm.el.attrs.length === 1 && etm.attr.name === ngfor) { + // this is the case where we're migrating an ngFor and there's no need to keep the ng-container + const childStart = etm.el.children[0].sourceSpan.start.offset - etm.nestCount - offset; + const childEnd = + etm.el.children[etm.el.children.length - 1].sourceSpan.end.offset - etm.nestCount - offset; + return tmpl.slice(childStart, childEnd); + } + const attrStart = etm.attr.keySpan!.start.offset - 1 - etm.nestCount - offset; + const valEnd = etm.attr.valueSpan!.end.offset + 1 - etm.nestCount - offset; + const start = tmpl.slice(etm.start(offset), attrStart); + const end = tmpl.slice(valEnd, etm.end(offset)); + return start + end; +} + +function migrateNgSwitch( + etm: ElementToMigrate, tmpl: string, offset: number): {tmpl: string, offset: number} { + const condition = etm.attr.value; + const startBlock = `@switch (${condition}) { `; + + const {openTag, closeTag, children} = getSwitchBlockElements(etm, tmpl, offset); + const cases = getSwitchCases(children, tmpl, etm.nestCount, offset); + const switchBlock = openTag + startBlock + cases.join(' ') + `}` + closeTag; + const updatedTmpl = tmpl.slice(0, etm.start(offset)) + switchBlock + tmpl.slice(etm.end(offset)); + + const difference = etm.length() - switchBlock.length; + + offset = offset + difference; + + return {tmpl: updatedTmpl, offset}; +} + +function getSwitchBlockElements(etm: ElementToMigrate, tmpl: string, offset: number) { + const attrStart = etm.attr.keySpan!.start.offset - 1 - etm.nestCount - offset; + const valEnd = etm.attr.valueSpan!.end.offset + 1 - etm.nestCount - offset; + const childStart = etm.el.children[0].sourceSpan.start.offset - etm.nestCount - offset; + const childEnd = + etm.el.children[etm.el.children.length - 1].sourceSpan.end.offset - etm.nestCount - offset; + let openTag = tmpl.slice(etm.start(offset), attrStart) + tmpl.slice(valEnd, childStart); + if (tmpl.slice(childStart, childStart + 1) === '\n') { + openTag += '\n'; + } + let closeTag = tmpl.slice(childEnd, etm.end(offset)); + if (tmpl.slice(childEnd - 1, childEnd) === '\n') { + closeTag = '\n' + closeTag; + } + return { + openTag, + closeTag, + children: etm.el.children, + }; +} + +function getSwitchCases(children: Node[], tmpl: string, nestCount: number, offset: number) { + const collector = new CaseCollector(); + visitAll(collector, children); + return collector.elements.map(etm => getSwitchCaseBlock(etm, tmpl, nestCount, offset)); +} + +function getSwitchCaseBlock( + etm: ElementToMigrate, tmpl: string, nestCount: number, offset: number): string { + const elStart = etm.el.sourceSpan?.start.offset - nestCount - offset; + const elEnd = etm.el.sourceSpan?.end.offset - nestCount - offset; + // beginning of the ngIf minus a leading space + const attrStart = etm.attr.keySpan!.start.offset - 1 - nestCount - offset; + // ngSwitchDefault case has no valueSpan and relies on the end of the key + const attrEnd = etm.attr.keySpan!.end.offset - nestCount - offset; + if (etm.attr.name === '*ngSwitchDefault') { + return `@default { ${tmpl.slice(elStart, attrStart) + tmpl.slice(attrEnd, elEnd)} }`; + } + // ngSwitchCase has a valueSpan + const valEnd = etm.attr.valueSpan!.end.offset + 1 - nestCount - offset; + return `@case (${etm.attr.value}) { ${ + tmpl.slice(elStart, attrStart) + tmpl.slice(valEnd, elEnd)} }`; +} + +/** Finds all elements with control flow structural directives. */ +class ElementCollector extends RecursiveVisitor { + readonly elements: ElementToMigrate[] = []; + readonly templates: Map = new Map(); + + override visitElement(el: Element): void { + if (el.attrs.length > 0) { + for (const attr of el.attrs) { + if (attributesToMigrate.includes(attr.name)) { + this.elements.push(new ElementToMigrate(el, attr)); + } + } + } + if (el.name === 'ng-template') { + for (const attr of el.attrs) { + if (attr.name.startsWith('#')) { + this.elements.push(new ElementToMigrate(el, attr)); + this.templates.set(attr.name, new Template(el)); + } + } + } + super.visitElement(el, null); + } +} + +class CaseCollector extends RecursiveVisitor { + readonly elements: ElementToMigrate[] = []; + + override visitElement(el: Element): void { + if (el.attrs.length > 0) { + for (const attr of el.attrs) { + if (casesToMigrate.includes(attr.name)) { + this.elements.push(new ElementToMigrate(el, attr)); + } + } + } + + super.visitElement(el, null); + } +} diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel index 7cb1de636229c..68397ee676a3f 100644 --- a/packages/core/schematics/test/BUILD.bazel +++ b/packages/core/schematics/test/BUILD.bazel @@ -23,6 +23,9 @@ jasmine_node_test( "//packages/core/schematics/migrations/block-template-entities:bundle", "//packages/core/schematics/migrations/compiler-options", "//packages/core/schematics/migrations/compiler-options:bundle", + "//packages/core/schematics/ng-generate/control-flow-migration", + "//packages/core/schematics/ng-generate/control-flow-migration:bundle", + "//packages/core/schematics/ng-generate/control-flow-migration:static_files", "//packages/core/schematics/ng-generate/standalone-migration", "//packages/core/schematics/ng-generate/standalone-migration:bundle", "//packages/core/schematics/ng-generate/standalone-migration:static_files", diff --git a/packages/core/schematics/test/control_flow_migration_spec.ts b/packages/core/schematics/test/control_flow_migration_spec.ts new file mode 100644 index 0000000000000..e6f59566a34ff --- /dev/null +++ b/packages/core/schematics/test/control_flow_migration_spec.ts @@ -0,0 +1,961 @@ +/** + * @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 {getSystemPath, normalize, virtualFs} from '@angular-devkit/core'; +import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing'; +import {HostTree} from '@angular-devkit/schematics'; +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; +import {runfiles} from '@bazel/runfiles'; +import shx from 'shelljs'; + +describe('control flow migration', () => { + let runner: SchematicTestRunner; + let host: TempScopedNodeJsSyncHost; + let tree: UnitTestTree; + let tmpDirPath: string; + let previousWorkingDir: string; + + function writeFile(filePath: string, contents: string) { + host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents)); + } + + function runMigration() { + return runner.runSchematic('control-flow-migration', {}, tree); + } + + beforeEach(() => { + runner = new SchematicTestRunner('test', runfiles.resolvePackageRelative('../collection.json')); + host = new TempScopedNodeJsSyncHost(); + tree = new UnitTestTree(new HostTree(host)); + + writeFile('/tsconfig.json', '{}'); + writeFile('/angular.json', JSON.stringify({ + version: 1, + projects: {t: {root: '', architect: {build: {options: {tsConfig: './tsconfig.json'}}}}} + })); + + previousWorkingDir = shx.pwd(); + tmpDirPath = getSystemPath(host.root); + + // Switch into the temporary directory path. This allows us to run + // the schematic against our custom unit test tree. + shx.cd(tmpDirPath); + }); + + afterEach(() => { + shx.cd(previousWorkingDir); + shx.rm('-r', tmpDirPath); + }); + + describe('ngIf', () => { + it('should migrate an inline template', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + imports: [NgIf], + template: \`
This should be hidden
\` + }) + class Comp { + toggle = false; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `
@if (toggle) {This should be hidden}
`'); + }); + + it('should migrate multiple inline templates in the same file', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + imports: [NgIf], + template: \`
This should be hidden
\` + }) + class Comp { + toggle = false; + } + + @Component({ + template: \`
An Article
\` + }) + class OtherComp { + show = 5 + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `
@if (toggle) {This should be hidden}
`'); + expect(content).toContain('template: `@if (show === 5) {
An Article
}`'); + }); + + it('should migrate an external template', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + show = false; + } + `); + + writeFile('/comp.html', [ + `
`, + `Content here`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `
`, + `@if (show) {Content here}`, + `
`, + ].join('\n')); + }); + + it('should migrate a template referenced by multiple components', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class Comp {} + `); + + writeFile('/other-comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class OtherComp {} + `); + + writeFile('/comp.html', [ + `
`, + `Content here`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `
`, + `@if (show) {Content here}`, + `
`, + ].join('\n')); + }); + + it('should migrate an if else case', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + show = false; + } + `); + + writeFile('/comp.html', [ + `
`, + `Content here`, + `Else Content`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `
`, + `@if (show) {Content here} @else {Else Content}`, + `
`, + ].join('\n')); + }); + + it('should migrate an if else case when the template is above the block', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + show = false; + } + `); + + writeFile('/comp.html', [ + `
`, + `Else Content`, + `Content here`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `
`, + `@if (show) {Content here} @else {Else Content}`, + `
`, + ].join('\n')); + }); + + it('should migrate an if then else case', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + show = false; + } + `); + + writeFile('/comp.html', [ + `
`, + `Ignored`, + `
THEN Stuff
`, + `Else Content`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `
`, + `@if (show) {
THEN Stuff
} @else {Else Content}`, + `
`, + ].join('\n')); + }); + + it('should migrate an if then else case with templates in odd places', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + show = false; + } + `); + + writeFile('/comp.html', [ + `
`, + `Else Content`, + `Ignored`, + `
THEN Stuff
`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `
`, + `@if (show) {
THEN Stuff
} @else {Else Content}`, + `
`, + ].join('\n')); + }); + + it('should migrate but not remove ng-templates when referenced elsewhere', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + show = false; + } + `); + + writeFile('/comp.html', [ + `
`, + `Ignored`, + `
THEN Stuff
`, + `Else Content`, + `
`, + ``, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `
`, + `@if (show) {
THEN Stuff
} @else {Else Content}`, + `Else Content`, + `
`, + ``, + ].join('\n')); + }); + }); + + describe('ngFor', () => { + it('should migrate an inline template', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + + @Component({ + imports: [NgFor], + template: \`
  • {{item.text}}
\` + }) + class Comp { + items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `
    @for (item of items; track item) {
  • {{item.text}}
  • }
`'); + }); + + it('should migrate multiple inline templates in the same file', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + const items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + + @Component({ + imports: [NgFor], + template: \`
  • {{item.text}}
\` + }) + class Comp { + items: Item[] = s; + } + + @Component({ + imports: [NgFor], + template: \`
{{item.text}}
\` + }) + class OtherComp { + items: Item[] = s; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `
    @for (item of items; track item) {
  • {{item.text}}
  • }
`'); + expect(content).toContain( + 'template: `
@for (item of items; track item) {
{{item.text}}
}
`'); + }); + + it('should migrate an external template', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + interface Item { + id: number; + text: string; + } + const items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + items: Item[] = s; + } + `); + + writeFile('/comp.html', [ + `
    `, + `
  • {{item.text}}
  • `, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `
    `, + `@for (item of items; track item) {
  • {{item.text}}
  • }`, + `
`, + ].join('\n')); + }); + + it('should migrate a template referenced by multiple components', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + interface Item { + id: number; + text: string; + } + const items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + items: Item[] = s; + } + `); + + writeFile('/other-comp.ts', ` + import {Component} from '@angular/core'; + interface Item { + id: number; + text: string; + } + const items: Item[] = [{id: 3, text: 'things'},{id: 4, text: 'yup'}]; + + @Component({ + templateUrl: './comp.html' + }) + class OtherComp { + items: Item[] = s; + } + `); + + writeFile('/comp.html', [ + `
    `, + `
  • {{item.text}}
  • `, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `
    `, + `@for (item of items; track item) {
  • {{item.text}}
  • }`, + `
`, + ].join('\n')); + }); + + it('should migrate with a trackBy function', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + + @Component({ + imports: [NgFor], + template: \`
  • {{itm.text}}
\` + }) + class Comp { + items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `
    @for (itm of items; track trackMeFn($index, itm)) {
  • {{itm.text}}
  • }
`'); + }); + + + it('should migrate with an index alias', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + + @Component({ + imports: [NgFor], + template: \`
  • {{itm.text}}
\` + }) + class Comp { + items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `
    @for (itm of items; track itm; let index = $index) {
  • {{itm.text}}
  • }
`'); + }); + + it('should migrate with multiple aliases', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + + @Component({ + imports: [NgFor], + template: \`
  • {{itm.text}}
\` + }) + class Comp { + items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `
    @for (itm of items; track itm; let count = $count; let first = $first; let last = $last; let ev = $even; let od = $odd) {
  • {{itm.text}}
  • }
`'); + }); + + it('should remove unneeded ng-containers', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + + @Component({ + imports: [NgFor], + template: \`

{{item.text}}

\` + }) + class Comp { + items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `@for (item of items; track item) {

{{item.text}}

}`'); + }); + + it('should leave ng-containers with additional attributes', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + + @Component({ + imports: [NgFor], + template: \`

{{item.text}}

\` + }) + class Comp { + items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `@for (item of items; track item) {

{{item.text}}

}`'); + }); + }); + + describe('ngSwitch', () => { + it('should migrate an inline template', async () => { + writeFile( + '/comp.ts', + ` + import {Component} from '@angular/core'; + import {ngSwitch, ngSwitchCase} from '@angular/common'; + + @Component({ + template: \`
` + + `

Option 1

` + + `

Option 2

` + + `
\` + }) + class Comp { + testOpts = "1"; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `
@switch (testOpts) { @case (1) {

Option 1

} @case (2) {

Option 2

}}
`'); + }); + + it('should migrate multiple inline templates in the same file', async () => { + writeFile( + '/comp.ts', + ` + import {Component} from '@angular/core'; + import {ngSwitch, ngSwitchCase} from '@angular/common'; + + @Component({ + template: \`
` + + `

Option 1

` + + `

Option 2

` + + `
\` + }) + class Comp1 { + testOpts = "1"; + } + + @Component({ + template: \`
` + + `

Choice A

` + + `

Choice B

` + + `

Choice Default

` + + `
\` + }) + class Comp2 { + choices = "C"; + } + `); + + await runMigration(); + const content = tree.readContent('/comp.ts'); + + expect(content).toContain( + 'template: `
@switch (testOpts) { @case (1) {

Option 1

} @case (2) {

Option 2

}}
`'); + expect(content).toContain( + 'template: `
@switch (choices) { @case (A) {

Choice A

} @case (B) {

Choice B

} @default {

Choice Default

}}
`'); + }); + + it('should migrate an external template', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + testOpts = 2; + } + `); + + writeFile('/comp.html', [ + `
`, + `

Option 1

`, + `

Option 2

`, + `

Option 3

`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + expect(content).toBe([ + `
`, + `@switch (testOpts) { @case (1) {

Option 1

} @case (2) {

Option 2

} @default {

Option 3

}}`, + `
`, + ].join('\n')); + }); + + it('should migrate a template referenced by multiple components', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {ngSwitch, ngSwitchCase} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class Comp { + testOpts = 2; + } + `); + + writeFile('/other-comp.ts', ` + import {Component} from '@angular/core'; + import {ngSwitch, ngSwitchCase} from '@angular/common'; + + @Component({ + templateUrl: './comp.html' + }) + class OtherComp { + testOpts = 1; + } + `); + + writeFile('/comp.html', [ + `
`, + `

Option 1

`, + `

Option 2

`, + `

Option 3

`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `
`, + `@switch (testOpts) { @case (1) {

Option 1

} @case (2) {

Option 2

} @default {

Option 3

}}`, + `
`, + ].join('\n')); + }); + }); + + describe('nested structures', () => { + it('should migrate an inline template with multiple nested control flow structures', + async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf} from '@angular/common'; + + @Component({ + imports: [NgFor, NgIf], + templateUrl: './comp.html' + }) + class Comp { + show = false; + nest = true; + again = true; + more = true; + } + `); + + writeFile('/comp.html', [ + `
`, + `
`, + `things`, + `stuff`, + `
`, + `stuff`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `@if (show) {
`, + `@if (nest) {
`, + `@if (again) {things}`, + `@if (more) {stuff}`, + `
}`, + `@if (more) {stuff}`, + `
}`, + ].join('\n')); + }); + + it('should migrate an inline template with if and for loops', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf, NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + + @Component({ + imports: [NgFor, NgIf], + templateUrl: './comp.html' + }) + class Comp { + show = false; + nest = true; + items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + } + `); + + writeFile('/comp.html', [ + `
`, + `
    `, + `
  • {{item.text}}
  • `, + `
`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `@if (show) {
`, + `@if (nest) {
    `, + `@for (item of items; track item) {
  • {{item.text}}
  • }`, + `
}`, + `
}`, + ].join('\n')); + }); + + it('should migrate an inline template with if, else and for loops', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf, NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + + @Component({ + imports: [NgFor, NgIf], + templateUrl: './comp.html' + }) + class Comp { + show = false; + nest = true; + items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + } + `); + + writeFile('/comp.html', [ + `
`, + `
    `, + `
  • {{item.text}}
  • `, + `
`, + `

Else content

`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `@if (show) {
`, + `@if (nest) {
    `, + `@for (item of items; track item) {
  • {{item.text}}
  • }`, + `
} @else {

Else content

}`, + `
}`, + ].join('\n')); + }); + + it('should migrate an inline template with if, else, and for loops', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf, NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + + @Component({ + imports: [NgFor, NgIf], + templateUrl: './comp.html' + }) + class Comp { + show = false; + nest = true; + items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + } + `); + + writeFile('/comp.html', [ + `
`, + `
    `, + `
  • {{item.text}}
  • `, + `
`, + `

Else content

`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `@if (show) {
`, + `@if (nest) {
    `, + `@for (item of items; track item) {
  • {{item.text}}
  • }`, + `
} @else {

Else content

}`, + `
}`, + ].join('\n')); + }); + + it('should migrate an inline template with if, for loops, and switches', async () => { + writeFile('/comp.ts', ` + import {Component} from '@angular/core'; + import {NgIf, NgFor} from '@angular/common'; + interface Item { + id: number; + text: string; + } + + @Component({ + imports: [NgFor, NgIf], + templateUrl: './comp.html' + }) + class Comp { + show = false; + nest = true; + again = true; + more = true; + items: Item[] = [{id: 1, text: 'blah'},{id: 2, text: 'stuff'}]; + testOpts = 2; + } + `); + + writeFile('/comp.html', [ + `
`, + `
`, + `
`, + `
`, + `

Option 1

`, + `

Option 2

`, + `

Option 3

`, + `
`, + `
`, + `
`, + `
    `, + `
  • {{item.text}}
  • `, + `
`, + `
`, + ].join('\n')); + + await runMigration(); + const content = tree.readContent('/comp.html'); + + expect(content).toBe([ + `@if (show) {
`, + `@if (again) {
`, + `@if (more) {
`, + `
`, + `@switch (testOpts) { @case (1) {

Option 1

} @case (2) {

Option 2

} @default {

Option 3

}}`, + `
`, + `
}`, + `
}`, + `@if (nest) {
    `, + `@for (item of items; track item) {
  • {{item.text}}
  • }`, + `
}`, + `
}`, + ].join('\n')); + }); + }); +});