Skip to content

feat(material/schematics): v15 ng-update scss migration #25395

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Aug 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/material/schematics/ng-update/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ ts_library(
"@npm//@angular-devkit/schematics",
"@npm//@schematics/angular",
"@npm//@types/node",
"@npm//postcss",
"@npm//postcss-scss",
"@npm//typescript",
],
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* @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
*/

export const COMPONENTS = [
'autocomplete',
'button',
'card',
'checkbox',
'chips',
'dialog',
'form-field',
'input',
'menu',
'option',
'optgroup',
'paginator',
'progress-bar',
'progress-spinner',
'radio',
'select',
'slide-toggle',
'snack-bar',
'table',
'tabs',
'tooltip',
];

export const MIXINS = COMPONENTS.flatMap(component => [
`${component}-theme`,
`${component}-color`,
`${component}-density`,
`${component}-typography`,
]);
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,68 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Migration, TargetVersion} from '@angular/cdk/schematics';
import * as ts from 'typescript';
import * as postcss from 'postcss';
import * as scss from 'postcss-scss';

import {MIXINS} from './constants';

import {Migration, ResolvedResource, TargetVersion, WorkspacePath} from '@angular/cdk/schematics';

export class LegacyComponentsMigration extends Migration<null> {
enabled = this.targetVersion === TargetVersion.V15;

override visitStylesheet(stylesheet: ResolvedResource): void {
let namespace: string | undefined = undefined;
const processor = new postcss.Processor([
{
postcssPlugin: 'legacy-components-v15-plugin',
AtRule: {
use: node => {
namespace = namespace ?? this._parseSassNamespace(node);
},
include: node => this._handleAtInclude(node, stylesheet.filePath, namespace),
},
},
]);
processor.process(stylesheet.content, {syntax: scss}).sync();
}

/** Returns the namespace of the given at-rule if it is importing from @angular/material. */
private _parseSassNamespace(node: postcss.AtRule): string | undefined {
if (node.params.startsWith('@angular/material', 1)) {
return node.params.split(/\s+/).pop();
}
return;
}

/** Handles updating the at-include rules of legacy component mixins. */
private _handleAtInclude(
node: postcss.AtRule,
filePath: WorkspacePath,
namespace?: string,
): void {
if (!namespace || !node.source?.start) {
return;
}
if (this._isLegacyMixin(node, namespace)) {
this._replaceAt(filePath, node.source.start.offset, {
old: `${namespace}.`,
new: `${namespace}.legacy-`,
});
}
}

/** Returns true if the given at-include rule is a use of a legacy component mixin. */
private _isLegacyMixin(node: postcss.AtRule, namespace: string): boolean {
for (let i = 0; i < MIXINS.length; i++) {
if (node.params.startsWith(`${namespace}.${MIXINS[i]}`)) {
return true;
}
}
return false;
}

override visitNode(node: ts.Node): void {
if (ts.isImportDeclaration(node)) {
this._handleImportDeclaration(node);
Expand Down Expand Up @@ -40,7 +96,7 @@ export class LegacyComponentsMigration extends Migration<null> {
const newExport = n.propertyName
? `MatLegacy${suffix}`
: `MatLegacy${suffix}: Mat${suffix}`;
this._replaceAt(name, {old: oldExport, new: newExport});
this._tsReplaceAt(name, {old: oldExport, new: newExport});
}
}
}
Expand All @@ -49,7 +105,7 @@ export class LegacyComponentsMigration extends Migration<null> {
private _handleImportDeclaration(node: ts.ImportDeclaration): void {
const moduleSpecifier = node.moduleSpecifier as ts.StringLiteral;
if (moduleSpecifier.text.startsWith('@angular/material/')) {
this._replaceAt(node, {old: '@angular/material/', new: '@angular/material/legacy-'});
this._tsReplaceAt(node, {old: '@angular/material/', new: '@angular/material/legacy-'});

if (node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) {
this._handleNamedImportBindings(node.importClause.namedBindings);
Expand All @@ -61,7 +117,7 @@ export class LegacyComponentsMigration extends Migration<null> {
private _handleImportExpression(node: ts.CallExpression): void {
const moduleSpecifier = node.arguments[0] as ts.StringLiteral;
if (moduleSpecifier.text.startsWith('@angular/material/')) {
this._replaceAt(node, {old: '@angular/material/', new: '@angular/material/legacy-'});
this._tsReplaceAt(node, {old: '@angular/material/', new: '@angular/material/legacy-'});
}
}

Expand All @@ -75,7 +131,7 @@ export class LegacyComponentsMigration extends Migration<null> {
const newExport = n.propertyName
? `MatLegacy${suffix}`
: `MatLegacy${suffix} as Mat${suffix}`;
this._replaceAt(name, {old: oldExport, new: newExport});
this._tsReplaceAt(name, {old: oldExport, new: newExport});
}
}

Expand Down Expand Up @@ -108,10 +164,19 @@ export class LegacyComponentsMigration extends Migration<null> {
);
}

/** Updates the source file of the given node with the given replacements. */
private _replaceAt(node: ts.Node, str: {old: string; new: string}): void {
/** Updates the source file of the given ts node with the given replacements. */
private _tsReplaceAt(node: ts.Node, str: {old: string; new: string}): void {
const filePath = this.fileSystem.resolve(node.getSourceFile().fileName);
const index = this.fileSystem.read(filePath)!.indexOf(str.old, node.pos);
this._replaceAt(filePath, node.pos, str);
}

/** Updates the source file with the given replacements. */
private _replaceAt(
filePath: WorkspacePath,
offset: number,
str: {old: string; new: string},
): void {
const index = this.fileSystem.read(filePath)!.indexOf(str.old, offset);
this.fileSystem.edit(filePath).remove(index, str.old.length).insertRight(index, str.new);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {UnitTestTree} from '@angular-devkit/schematics/testing';
import {createTestCaseSetup} from '@angular/cdk/schematics/testing';
import {join} from 'path';
import {COMPONENTS} from '../../migrations/legacy-components-v15/constants';
import {MIGRATION_PATH} from '../../../paths';

const PROJECT_ROOT_DIR = '/projects/cdk-testing';
Expand All @@ -13,8 +14,14 @@ describe('v15 legacy components migration', () => {
/** Writes an single line file. */
let writeLine: (path: string, line: string) => void;

/** Reads a file. */
let readFile: (path: string) => string;
/** Writes multiple lines to a file. */
let writeLines: (path: string, lines: string[]) => void;

/** Reads a single line file. */
let readLine: (path: string) => string;

/** Reads multiple lines from a file. */
let readLines: (path: string) => string[];

/** Runs the v15 migration on the test application. */
let runMigration: () => Promise<{logOutput: string}>;
Expand All @@ -23,15 +30,17 @@ describe('v15 legacy components migration', () => {
const testSetup = await createTestCaseSetup('migration-v15', MIGRATION_PATH, []);
tree = testSetup.appTree;
runMigration = testSetup.runFixers;
readFile = (path: string) => tree.readContent(path);
readLine = (path: string) => tree.readContent(path);
readLines = (path: string) => tree.readContent(path).split('\n');
writeLine = (path: string, lines: string) => testSetup.writeFile(path, lines);
writeLines = (path: string, lines: string[]) => testSetup.writeFile(path, lines.join('\n'));
});

describe('typescript migrations', () => {
async function runTypeScriptMigrationTest(ctx: string, opts: {old: string; new: string}) {
writeLine(TS_FILE_PATH, opts.old);
await runMigration();
expect(readFile(TS_FILE_PATH)).withContext(ctx).toEqual(opts.new);
expect(readLine(TS_FILE_PATH)).withContext(ctx).toEqual(opts.new);
}

it('updates import declarations', async () => {
Expand Down Expand Up @@ -74,10 +83,65 @@ describe('v15 legacy components migration', () => {
});

describe('style migrations', () => {
it('should do nothing yet', async () => {
writeLine(THEME_FILE_PATH, ' ');
async function runSassMigrationTest(ctx: string, opts: {old: string[]; new: string[]}) {
writeLines(THEME_FILE_PATH, opts.old);
await runMigration();
expect(readFile(THEME_FILE_PATH)).toEqual(' ');
expect(readLines(THEME_FILE_PATH)).withContext(ctx).toEqual(opts.new);
}

it('updates all mixins', async () => {
const oldFile: string[] = [`@use '@angular/material' as mat;`];
const newFile: string[] = [`@use '@angular/material' as mat;`];
for (let i = 0; i < COMPONENTS.length; i++) {
oldFile.push(
...[
`@include mat.${COMPONENTS[i]}-theme($theme);`,
`@include mat.${COMPONENTS[i]}-color($theme);`,
`@include mat.${COMPONENTS[i]}-density($theme);`,
`@include mat.${COMPONENTS[i]}-typography($theme);`,
],
);
newFile.push(
...[
`@include mat.legacy-${COMPONENTS[i]}-theme($theme);`,
`@include mat.legacy-${COMPONENTS[i]}-color($theme);`,
`@include mat.legacy-${COMPONENTS[i]}-density($theme);`,
`@include mat.legacy-${COMPONENTS[i]}-typography($theme);`,
],
);
}
await runSassMigrationTest('all components', {
old: oldFile,
new: newFile,
});
await runSassMigrationTest('w/ unique namespaces', {
old: [`@use '@angular/material' as material;`, `@include material.button-theme($theme);`],
new: [
`@use '@angular/material' as material;`,
`@include material.legacy-button-theme($theme);`,
],
});
await runSassMigrationTest('w/ unique whitespace', {
old: [
` @use '@angular/material' as material ; `,
` @include material.button-theme( $theme ) ; `,
],
new: [
` @use '@angular/material' as material ; `,
` @include material.legacy-button-theme( $theme ) ; `,
],
});
});

it('does not update non-mdc component mixins', async () => {
await runSassMigrationTest('datepicker', {
old: [`@use '@angular/material' as mat;`, `@include mat.datepicker-theme($theme);`],
new: [`@use '@angular/material' as mat;`, `@include mat.datepicker-theme($theme);`],
});
await runSassMigrationTest('button-toggle', {
old: [`@use '@angular/material' as mat;`, `@include mat.button-toggle-theme($theme);`],
new: [`@use '@angular/material' as mat;`, `@include mat.button-toggle-theme($theme);`],
});
});
});
});