Skip to content

Commit 00d5f27

Browse files
authored
feat(material/schematics): v15 ng-update scss migration (#25395)
* feat(material/schematics): v15 ng-update scss migration * fixup! feat(material/schematics): v15 ng-update scss migration * fixup! feat(material/schematics): v15 ng-update scss migration
1 parent c7c9a25 commit 00d5f27

File tree

4 files changed

+184
-15
lines changed

4 files changed

+184
-15
lines changed

src/material/schematics/ng-update/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ ts_library(
2020
"@npm//@angular-devkit/schematics",
2121
"@npm//@schematics/angular",
2222
"@npm//@types/node",
23+
"@npm//postcss",
24+
"@npm//postcss-scss",
2325
"@npm//typescript",
2426
],
2527
)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
export const COMPONENTS = [
10+
'autocomplete',
11+
'button',
12+
'card',
13+
'checkbox',
14+
'chips',
15+
'dialog',
16+
'form-field',
17+
'input',
18+
'menu',
19+
'option',
20+
'optgroup',
21+
'paginator',
22+
'progress-bar',
23+
'progress-spinner',
24+
'radio',
25+
'select',
26+
'slide-toggle',
27+
'snack-bar',
28+
'table',
29+
'tabs',
30+
'tooltip',
31+
];
32+
33+
export const MIXINS = COMPONENTS.flatMap(component => [
34+
`${component}-theme`,
35+
`${component}-color`,
36+
`${component}-density`,
37+
`${component}-typography`,
38+
]);

src/material/schematics/ng-update/migrations/legacy-components-v15/index.ts

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,68 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Migration, TargetVersion} from '@angular/cdk/schematics';
109
import * as ts from 'typescript';
10+
import * as postcss from 'postcss';
11+
import * as scss from 'postcss-scss';
12+
13+
import {MIXINS} from './constants';
14+
15+
import {Migration, ResolvedResource, TargetVersion, WorkspacePath} from '@angular/cdk/schematics';
1116

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

20+
override visitStylesheet(stylesheet: ResolvedResource): void {
21+
let namespace: string | undefined = undefined;
22+
const processor = new postcss.Processor([
23+
{
24+
postcssPlugin: 'legacy-components-v15-plugin',
25+
AtRule: {
26+
use: node => {
27+
namespace = namespace ?? this._parseSassNamespace(node);
28+
},
29+
include: node => this._handleAtInclude(node, stylesheet.filePath, namespace),
30+
},
31+
},
32+
]);
33+
processor.process(stylesheet.content, {syntax: scss}).sync();
34+
}
35+
36+
/** Returns the namespace of the given at-rule if it is importing from @angular/material. */
37+
private _parseSassNamespace(node: postcss.AtRule): string | undefined {
38+
if (node.params.startsWith('@angular/material', 1)) {
39+
return node.params.split(/\s+/).pop();
40+
}
41+
return;
42+
}
43+
44+
/** Handles updating the at-include rules of legacy component mixins. */
45+
private _handleAtInclude(
46+
node: postcss.AtRule,
47+
filePath: WorkspacePath,
48+
namespace?: string,
49+
): void {
50+
if (!namespace || !node.source?.start) {
51+
return;
52+
}
53+
if (this._isLegacyMixin(node, namespace)) {
54+
this._replaceAt(filePath, node.source.start.offset, {
55+
old: `${namespace}.`,
56+
new: `${namespace}.legacy-`,
57+
});
58+
}
59+
}
60+
61+
/** Returns true if the given at-include rule is a use of a legacy component mixin. */
62+
private _isLegacyMixin(node: postcss.AtRule, namespace: string): boolean {
63+
for (let i = 0; i < MIXINS.length; i++) {
64+
if (node.params.startsWith(`${namespace}.${MIXINS[i]}`)) {
65+
return true;
66+
}
67+
}
68+
return false;
69+
}
70+
1571
override visitNode(node: ts.Node): void {
1672
if (ts.isImportDeclaration(node)) {
1773
this._handleImportDeclaration(node);
@@ -40,7 +96,7 @@ export class LegacyComponentsMigration extends Migration<null> {
4096
const newExport = n.propertyName
4197
? `MatLegacy${suffix}`
4298
: `MatLegacy${suffix}: Mat${suffix}`;
43-
this._replaceAt(name, {old: oldExport, new: newExport});
99+
this._tsReplaceAt(name, {old: oldExport, new: newExport});
44100
}
45101
}
46102
}
@@ -49,7 +105,7 @@ export class LegacyComponentsMigration extends Migration<null> {
49105
private _handleImportDeclaration(node: ts.ImportDeclaration): void {
50106
const moduleSpecifier = node.moduleSpecifier as ts.StringLiteral;
51107
if (moduleSpecifier.text.startsWith('@angular/material/')) {
52-
this._replaceAt(node, {old: '@angular/material/', new: '@angular/material/legacy-'});
108+
this._tsReplaceAt(node, {old: '@angular/material/', new: '@angular/material/legacy-'});
53109

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

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

@@ -108,10 +164,19 @@ export class LegacyComponentsMigration extends Migration<null> {
108164
);
109165
}
110166

111-
/** Updates the source file of the given node with the given replacements. */
112-
private _replaceAt(node: ts.Node, str: {old: string; new: string}): void {
167+
/** Updates the source file of the given ts node with the given replacements. */
168+
private _tsReplaceAt(node: ts.Node, str: {old: string; new: string}): void {
113169
const filePath = this.fileSystem.resolve(node.getSourceFile().fileName);
114-
const index = this.fileSystem.read(filePath)!.indexOf(str.old, node.pos);
170+
this._replaceAt(filePath, node.pos, str);
171+
}
172+
173+
/** Updates the source file with the given replacements. */
174+
private _replaceAt(
175+
filePath: WorkspacePath,
176+
offset: number,
177+
str: {old: string; new: string},
178+
): void {
179+
const index = this.fileSystem.read(filePath)!.indexOf(str.old, offset);
115180
this.fileSystem.edit(filePath).remove(index, str.old.length).insertRight(index, str.new);
116181
}
117182
}

src/material/schematics/ng-update/test-cases/v15/legacy-components-v15.spec.ts

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {UnitTestTree} from '@angular-devkit/schematics/testing';
22
import {createTestCaseSetup} from '@angular/cdk/schematics/testing';
33
import {join} from 'path';
4+
import {COMPONENTS} from '../../migrations/legacy-components-v15/constants';
45
import {MIGRATION_PATH} from '../../../paths';
56

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

16-
/** Reads a file. */
17-
let readFile: (path: string) => string;
17+
/** Writes multiple lines to a file. */
18+
let writeLines: (path: string, lines: string[]) => void;
19+
20+
/** Reads a single line file. */
21+
let readLine: (path: string) => string;
22+
23+
/** Reads multiple lines from a file. */
24+
let readLines: (path: string) => string[];
1825

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

3039
describe('typescript migrations', () => {
3140
async function runTypeScriptMigrationTest(ctx: string, opts: {old: string; new: string}) {
3241
writeLine(TS_FILE_PATH, opts.old);
3342
await runMigration();
34-
expect(readFile(TS_FILE_PATH)).withContext(ctx).toEqual(opts.new);
43+
expect(readLine(TS_FILE_PATH)).withContext(ctx).toEqual(opts.new);
3544
}
3645

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

7685
describe('style migrations', () => {
77-
it('should do nothing yet', async () => {
78-
writeLine(THEME_FILE_PATH, ' ');
86+
async function runSassMigrationTest(ctx: string, opts: {old: string[]; new: string[]}) {
87+
writeLines(THEME_FILE_PATH, opts.old);
7988
await runMigration();
80-
expect(readFile(THEME_FILE_PATH)).toEqual(' ');
89+
expect(readLines(THEME_FILE_PATH)).withContext(ctx).toEqual(opts.new);
90+
}
91+
92+
it('updates all mixins', async () => {
93+
const oldFile: string[] = [`@use '@angular/material' as mat;`];
94+
const newFile: string[] = [`@use '@angular/material' as mat;`];
95+
for (let i = 0; i < COMPONENTS.length; i++) {
96+
oldFile.push(
97+
...[
98+
`@include mat.${COMPONENTS[i]}-theme($theme);`,
99+
`@include mat.${COMPONENTS[i]}-color($theme);`,
100+
`@include mat.${COMPONENTS[i]}-density($theme);`,
101+
`@include mat.${COMPONENTS[i]}-typography($theme);`,
102+
],
103+
);
104+
newFile.push(
105+
...[
106+
`@include mat.legacy-${COMPONENTS[i]}-theme($theme);`,
107+
`@include mat.legacy-${COMPONENTS[i]}-color($theme);`,
108+
`@include mat.legacy-${COMPONENTS[i]}-density($theme);`,
109+
`@include mat.legacy-${COMPONENTS[i]}-typography($theme);`,
110+
],
111+
);
112+
}
113+
await runSassMigrationTest('all components', {
114+
old: oldFile,
115+
new: newFile,
116+
});
117+
await runSassMigrationTest('w/ unique namespaces', {
118+
old: [`@use '@angular/material' as material;`, `@include material.button-theme($theme);`],
119+
new: [
120+
`@use '@angular/material' as material;`,
121+
`@include material.legacy-button-theme($theme);`,
122+
],
123+
});
124+
await runSassMigrationTest('w/ unique whitespace', {
125+
old: [
126+
` @use '@angular/material' as material ; `,
127+
` @include material.button-theme( $theme ) ; `,
128+
],
129+
new: [
130+
` @use '@angular/material' as material ; `,
131+
` @include material.legacy-button-theme( $theme ) ; `,
132+
],
133+
});
134+
});
135+
136+
it('does not update non-mdc component mixins', async () => {
137+
await runSassMigrationTest('datepicker', {
138+
old: [`@use '@angular/material' as mat;`, `@include mat.datepicker-theme($theme);`],
139+
new: [`@use '@angular/material' as mat;`, `@include mat.datepicker-theme($theme);`],
140+
});
141+
await runSassMigrationTest('button-toggle', {
142+
old: [`@use '@angular/material' as mat;`, `@include mat.button-toggle-theme($theme);`],
143+
new: [`@use '@angular/material' as mat;`, `@include mat.button-toggle-theme($theme);`],
144+
});
81145
});
82146
});
83147
});

0 commit comments

Comments
 (0)