Skip to content

Commit

Permalink
fix(migrations): fixes migrations of nested switches in control flow (#…
Browse files Browse the repository at this point in the history
…53010)

This separates out the NgSwitch migration pass from the NgSwitchCase / Default pass, which makes nested switch migrations work.

fixes: #53009

PR Close #53010
  • Loading branch information
thePunderWoman authored and AndrewKushnir committed Nov 20, 2023
1 parent 5564d02 commit 28f6cbf
Show file tree
Hide file tree
Showing 4 changed files with 178 additions and 69 deletions.
122 changes: 122 additions & 0 deletions packages/core/schematics/ng-generate/control-flow-migration/cases.ts
@@ -0,0 +1,122 @@
/**
* @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 {visitAll} from '@angular/compiler';

import {ElementCollector, ElementToMigrate, MigrateError, Result} from './types';
import {calculateNesting, getMainBlock, getOriginals, hasLineBreaks, parseTemplate, reduceNestingOffset} from './util';

export const boundcase = '[ngSwitchCase]';
export const switchcase = '*ngSwitchCase';
export const nakedcase = 'ngSwitchCase';
export const switchdefault = '*ngSwitchDefault';
export const nakeddefault = 'ngSwitchDefault';

const cases = [
boundcase,
switchcase,
nakedcase,
switchdefault,
nakeddefault,
];

/**
* Replaces structural directive ngSwitch instances with new switch.
* Returns null if the migration failed (e.g. there was a syntax error).
*/
export function migrateCase(template: string): {migrated: string, errors: MigrateError[]} {
let errors: MigrateError[] = [];
let parsed = parseTemplate(template);
if (parsed === null) {
return {migrated: template, errors};
}

let result = template;
const visitor = new ElementCollector(cases);
visitAll(visitor, parsed.rootNodes);
calculateNesting(visitor, hasLineBreaks(template));

// 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;
let nestLevel = -1;
let postOffsets: number[] = [];
for (const el of visitor.elements) {
let migrateResult: Result = {tmpl: result, offsets: {pre: 0, post: 0}};
// applies the post offsets after closing
offset = reduceNestingOffset(el, nestLevel, offset, postOffsets);

if (el.attr.name === switchcase || el.attr.name === nakedcase || el.attr.name === boundcase) {
try {
migrateResult = migrateNgSwitchCase(el, result, offset);
} catch (error: unknown) {
errors.push({type: switchcase, error});
}
} else if (el.attr.name === switchdefault || el.attr.name === nakeddefault) {
try {
migrateResult = migrateNgSwitchDefault(el, result, offset);
} catch (error: unknown) {
errors.push({type: switchdefault, error});
}
}

result = migrateResult.tmpl;
offset += migrateResult.offsets.pre;
postOffsets.push(migrateResult.offsets.post);
nestLevel = el.nestCount;
}

return {migrated: result, errors};
}

function migrateNgSwitchCase(etm: ElementToMigrate, tmpl: string, offset: number): Result {
// includes the mandatory semicolon before as
const lbString = etm.hasLineBreaks ? '\n' : '';
const leadingSpace = etm.hasLineBreaks ? '' : ' ';
const condition = etm.attr.value;

const originals = getOriginals(etm, tmpl, offset);

const {start, middle, end} = getMainBlock(etm, tmpl, offset);
const startBlock = `${leadingSpace}@case (${condition}) {${leadingSpace}${lbString}${start}`;
const endBlock = `${end}${lbString}${leadingSpace}}`;

const defaultBlock = startBlock + middle + endBlock;
const updatedTmpl = tmpl.slice(0, etm.start(offset)) + defaultBlock + tmpl.slice(etm.end(offset));

// this should be the difference between the starting element up to the start of the closing
// element and the mainblock sans }
const pre = originals.start.length - startBlock.length;
const post = originals.end.length - endBlock.length;

return {tmpl: updatedTmpl, offsets: {pre, post}};
}

function migrateNgSwitchDefault(etm: ElementToMigrate, tmpl: string, offset: number): Result {
// includes the mandatory semicolon before as
const lbString = etm.hasLineBreaks ? '\n' : '';
const leadingSpace = etm.hasLineBreaks ? '' : ' ';

const originals = getOriginals(etm, tmpl, offset);

const {start, middle, end} = getMainBlock(etm, tmpl, offset);
const startBlock = `${leadingSpace}@default {${leadingSpace}${lbString}${start}`;
const endBlock = `${end}${lbString}${leadingSpace}}`;

const defaultBlock = startBlock + middle + endBlock;
const updatedTmpl = tmpl.slice(0, etm.start(offset)) + defaultBlock + tmpl.slice(etm.end(offset));

// this should be the difference between the starting element up to the start of the closing
// element and the mainblock sans }
const pre = originals.start.length - startBlock.length;
const post = originals.end.length - endBlock.length;

return {tmpl: updatedTmpl, offsets: {pre, post}};
}
Expand Up @@ -8,6 +8,7 @@

import ts from 'typescript';

import {migrateCase} from './cases';
import {migrateFor} from './fors';
import {migrateIf} from './ifs';
import {migrateSwitch} from './switches';
Expand All @@ -26,7 +27,8 @@ export function migrateTemplate(
const ifResult = migrateIf(template);
const forResult = migrateFor(ifResult.migrated);
const switchResult = migrateSwitch(forResult.migrated);
migrated = processNgTemplates(switchResult.migrated);
const caseResult = migrateCase(switchResult.migrated);
migrated = processNgTemplates(caseResult.migrated);
if (format) {
migrated = formatTemplate(migrated);
}
Expand All @@ -36,6 +38,7 @@ export function migrateTemplate(
...ifResult.errors,
...forResult.errors,
...switchResult.errors,
...caseResult.errors,
];
} else {
migrated = removeImports(template, node, file.removeCommonModule);
Expand Down
Expand Up @@ -12,19 +12,9 @@ import {ElementCollector, ElementToMigrate, MigrateError, Result} from './types'
import {calculateNesting, getMainBlock, getOriginals, hasLineBreaks, parseTemplate, reduceNestingOffset} from './util';

export const ngswitch = '[ngSwitch]';
export const boundcase = '[ngSwitchCase]';
export const switchcase = '*ngSwitchCase';
export const nakedcase = 'ngSwitchCase';
export const switchdefault = '*ngSwitchDefault';
export const nakeddefault = 'ngSwitchDefault';

const switches = [
ngswitch,
boundcase,
switchcase,
nakedcase,
switchdefault,
nakeddefault,
];

/**
Expand Down Expand Up @@ -61,19 +51,6 @@ export function migrateSwitch(template: string): {migrated: string, errors: Migr
} catch (error: unknown) {
errors.push({type: ngswitch, error});
}
} else if (
el.attr.name === switchcase || el.attr.name === nakedcase || el.attr.name === boundcase) {
try {
migrateResult = migrateNgSwitchCase(el, result, offset);
} catch (error: unknown) {
errors.push({type: ngswitch, error});
}
} else if (el.attr.name === switchdefault || el.attr.name === nakeddefault) {
try {
migrateResult = migrateNgSwitchDefault(el, result, offset);
} catch (error: unknown) {
errors.push({type: ngswitch, error});
}
}

result = migrateResult.tmpl;
Expand Down Expand Up @@ -105,48 +82,3 @@ function migrateNgSwitch(etm: ElementToMigrate, tmpl: string, offset: number): R

return {tmpl: updatedTmpl, offsets: {pre, post}};
}

function migrateNgSwitchCase(etm: ElementToMigrate, tmpl: string, offset: number): Result {
// includes the mandatory semicolon before as
const lbString = etm.hasLineBreaks ? '\n' : '';
const leadingSpace = etm.hasLineBreaks ? '' : ' ';
const condition = etm.attr.value;

const originals = getOriginals(etm, tmpl, offset);

const {start, middle, end} = getMainBlock(etm, tmpl, offset);
const startBlock = `${leadingSpace}@case (${condition}) {${leadingSpace}${lbString}${start}`;
const endBlock = `${end}${lbString}${leadingSpace}}`;

const defaultBlock = startBlock + middle + endBlock;
const updatedTmpl = tmpl.slice(0, etm.start(offset)) + defaultBlock + tmpl.slice(etm.end(offset));

// this should be the difference between the starting element up to the start of the closing
// element and the mainblock sans }
const pre = originals.start.length - startBlock.length;
const post = originals.end.length - endBlock.length;

return {tmpl: updatedTmpl, offsets: {pre, post}};
}

function migrateNgSwitchDefault(etm: ElementToMigrate, tmpl: string, offset: number): Result {
// includes the mandatory semicolon before as
const lbString = etm.hasLineBreaks ? '\n' : '';
const leadingSpace = etm.hasLineBreaks ? '' : ' ';

const originals = getOriginals(etm, tmpl, offset);

const {start, middle, end} = getMainBlock(etm, tmpl, offset);
const startBlock = `${leadingSpace}@default {${leadingSpace}${lbString}${start}`;
const endBlock = `${end}${lbString}${leadingSpace}}`;

const defaultBlock = startBlock + middle + endBlock;
const updatedTmpl = tmpl.slice(0, etm.start(offset)) + defaultBlock + tmpl.slice(etm.end(offset));

// this should be the difference between the starting element up to the start of the closing
// element and the mainblock sans }
const pre = originals.start.length - startBlock.length;
const post = originals.end.length - endBlock.length;

return {tmpl: updatedTmpl, offsets: {pre, post}};
}
52 changes: 52 additions & 0 deletions packages/core/schematics/test/control_flow_migration_spec.ts
Expand Up @@ -2125,6 +2125,58 @@ describe('control flow migration', () => {
expect(content).toContain(
'template: `<div>@switch (testOpts) { @case (1) { <p>Option 1</p> } @case (2) { <p>Option 2</p> }}</div>`');
});

it('should migrate nested switches', 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', [
`<div [ngSwitch]="thing">`,
` <div *ngSwitchCase="'item'" [ngSwitch]="anotherThing">`,
` <img *ngSwitchCase="'png'" src="/img.png" alt="PNG" />`,
` <img *ngSwitchDefault src="/default.jpg" alt="default" />`,
` </div>`,
` <img *ngSwitchDefault src="/default.jpg" alt="default" />`,
`</div>`,
].join('\n'));

await runMigration();
const content = tree.readContent('/comp.html');

expect(content).toBe([
`<div>`,
` @switch (thing) {`,
` @case ('item') {`,
` <div>`,
` @switch (anotherThing) {`,
` @case ('png') {`,
` <img src="/img.png" alt="PNG" />`,
` }`,
` @default {`,
` <img src="/default.jpg" alt="default" />`,
` }`,
` }`,
` </div>`,
` }`,
` @default {`,
` <img src="/default.jpg" alt="default" />`,
` }`,
` }`,
`</div>`,
].join('\n'));
});
});

describe('nested structures', () => {
Expand Down

0 comments on commit 28f6cbf

Please sign in to comment.