Skip to content

Commit 2c62056

Browse files
committed
WIP: feat migrate ngclass to class
1 parent 3e6e1c1 commit 2c62056

14 files changed

Lines changed: 885 additions & 0 deletions

File tree

adev/src/app/sub-navigation-data.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,11 @@ const REFERENCE_SUB_NAVIGATION_DATA: NavigationItem[] = [
13601360
path: 'reference/migrations/self-closing-tags',
13611361
contentPath: 'reference/migrations/self-closing-tags',
13621362
},
1363+
{
1364+
label: 'NgClass to Class',
1365+
path: 'reference/migrations/ngclass-to-class',
1366+
contentPath: 'reference/migrations/ngclass-to-class',
1367+
},
13631368
],
13641369
},
13651370
];
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Migration to ngclass to class
2+
3+
This schematic migrates the ngClass to class in your application.
4+
5+
Run the schematic using the following command:
6+
7+
```bash
8+
ng generate @angular/core:ngclass-to-class
9+
```
10+
11+
12+
#### Before
13+
14+
```html
15+
<div [ngClass]="{admin: isAdmin, dense: density === 'high'}">
16+
```
17+
18+
19+
#### After
20+
21+
```html
22+
<div [class.admin]="isAdmin" [class.dense]="density === 'high'">

adev/src/content/reference/migrations/overview.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,7 @@ Learn about how you can migrate your existing angular project to the latest feat
3030
<docs-card title="Self-closing tags" link="Migrate now" href="reference/migrations/self-closing-tags">
3131
Convert component templates to use self-closing tags where possible.
3232
</docs-card>
33+
<docs-card title="NgClass to Class Bindings" link="Migrate now" href="reference/migrations/ngclass-to-class">
34+
Convert component templates to prefer [class.class-name] bindings over [ngClass] object literals.
35+
</docs-card>
3336
</docs-card-container>

packages/core/schematics/BUILD.bazel

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ pkg_npm(
5151
"//packages/core/schematics/migrations/control-flow-migration:static_files",
5252
"//packages/core/schematics/ng-generate/cleanup-unused-imports:static_files",
5353
"//packages/core/schematics/ng-generate/inject-migration:static_files",
54+
"//packages/core/schematics/ng-generate/ngclass-to-class-migration:static_files",
5455
"//packages/core/schematics/ng-generate/output-migration:static_files",
5556
"//packages/core/schematics/ng-generate/route-lazy-loading:static_files",
5657
"//packages/core/schematics/ng-generate/self-closing-tags-migration:static_files",
@@ -114,6 +115,10 @@ bundle_entrypoints = [
114115
"control-flow-migration",
115116
"packages/core/schematics/migrations/control-flow-migration/index.js",
116117
],
118+
[
119+
"ngclass-to-class-migration",
120+
"packages/core/schematics/ng-generate/ngclass-to-class-migration/index.js",
121+
],
117122
]
118123

119124
rollup.rollup(
@@ -131,6 +136,7 @@ rollup.rollup(
131136
"//packages/core/schematics/migrations/test-bed-get",
132137
"//packages/core/schematics/ng-generate/cleanup-unused-imports",
133138
"//packages/core/schematics/ng-generate/inject-migration",
139+
"//packages/core/schematics/ng-generate/ngclass-to-class-migration",
134140
"//packages/core/schematics/ng-generate/output-migration",
135141
"//packages/core/schematics/ng-generate/route-lazy-loading",
136142
"//packages/core/schematics/ng-generate/self-closing-tags-migration",

packages/core/schematics/collection.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@
5757
"factory": "./bundles/control-flow-migration.cjs#migrate",
5858
"schema": "./migrations/control-flow-migration/schema.json",
5959
"aliases": ["control-flow"]
60+
},
61+
"ngclass-to-class-migration": {
62+
"description": "Updates usages of `ngClass` to the `class` attribute where possible",
63+
"factory": "./bundles/ngclass-to-class-migration.cjs#migrate",
64+
"schema": "./ng-generate/ngclass-to-class-migration/schema.json",
65+
"aliases": ["ngclass-to-class"]
6066
}
6167
}
6268
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
load("//tools:defaults2.bzl", "jasmine_test", "ts_project")
2+
3+
ts_project(
4+
name = "migration",
5+
srcs = glob(
6+
["**/*.ts"],
7+
exclude = ["*.spec.ts"],
8+
),
9+
visibility = [
10+
"//packages/core/schematics/ng-generate/ngclass-to-class-migration:__pkg__",
11+
"//packages/language-service/src/refactorings:__pkg__",
12+
],
13+
deps = [
14+
"//:node_modules/@types/node",
15+
"//:node_modules/typescript",
16+
"//packages/compiler",
17+
"//packages/compiler-cli",
18+
"//packages/compiler-cli/private",
19+
"//packages/compiler-cli/src/ngtsc/annotations",
20+
"//packages/compiler-cli/src/ngtsc/annotations/directive",
21+
"//packages/compiler-cli/src/ngtsc/file_system",
22+
"//packages/compiler-cli/src/ngtsc/imports",
23+
"//packages/compiler-cli/src/ngtsc/metadata",
24+
"//packages/compiler-cli/src/ngtsc/reflection",
25+
"//packages/core/schematics/utils",
26+
"//packages/core/schematics/utils/tsurge",
27+
],
28+
)
29+
30+
ts_project(
31+
name = "test_lib",
32+
testonly = True,
33+
srcs = glob(
34+
["**/*.spec.ts"],
35+
),
36+
deps = [
37+
":migration",
38+
"//packages/compiler-cli",
39+
"//packages/compiler-cli/src/ngtsc/file_system/testing",
40+
"//packages/core/schematics/utils/tsurge",
41+
],
42+
)
43+
44+
jasmine_test(
45+
name = "test",
46+
data = [":test_lib"],
47+
env = {"FORCE_COLOR": "3"},
48+
)
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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.dev/license
7+
*/
8+
9+
import {absoluteFrom} from '@angular/compiler-cli';
10+
import {diffText, runTsurgeMigration} from '../../utils/tsurge/testing';
11+
import {NgClassMigration} from './ngclass-to-class-migration';
12+
import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
13+
14+
describe('ngClass migrator', () => {
15+
beforeEach(() => {
16+
initMockFileSystem('Native');
17+
});
18+
19+
describe('No change cases', () => {
20+
it('should not change static HTML elements', async () => {
21+
await verifyDeclarationNoChange(`<button id="123"></button>`);
22+
});
23+
24+
it('should not change existing [class] bindings', async () => {
25+
await verifyDeclarationNoChange(`<div [class.active]="isActive"></div>`);
26+
});
27+
28+
it('should not change empty ngClass binding', async () => {
29+
await verifyDeclarationNoChange(`<div [ngClass]="{}"></div>`);
30+
});
31+
32+
it('should not change ngClass with empty string key', async () => {
33+
await verifyDeclarationNoChange(`<div [ngClass]="{'': condition}"></div>`);
34+
});
35+
36+
it('should not change ngClass with empty array', async () => {
37+
await verifyDeclarationNoChange(`<div [ngClass]="[]"></div>`);
38+
});
39+
});
40+
41+
describe('Simple ngClass object migrations', () => {
42+
it('should migrate single condition', async () => {
43+
await verifyDeclaration({
44+
before: `<div [ngClass]="{'active': isActive}"></div>`,
45+
after: `<div [class.active]="isActive"></div>`,
46+
});
47+
});
48+
49+
it('should migrate multiple class conditions', async () => {
50+
await verifyDeclaration({
51+
before: `<div [ngClass]="{'class1': condition1, 'class2': condition2}"></div>`,
52+
after: `<div [class.class1]="condition1" [class.class2]="condition2"></div>`,
53+
});
54+
});
55+
56+
it('should migrate quoted class names', async () => {
57+
await verifyDeclaration({
58+
before: `<div [ngClass]="{'admin-panel': isAdmin, 'user-dense': isDense}"></div>`,
59+
after: `<div [class.admin-panel]="isAdmin" [class.user-dense]="isDense"></div>`,
60+
});
61+
});
62+
63+
it('should split and migrate multiple classes in one key', async () => {
64+
await verifyDeclaration({
65+
before: `<div [ngClass]="{'class1 class2': condition}"></div>`,
66+
after: `<div [class.class1]="condition" [class.class2]="condition"></div>`,
67+
});
68+
});
69+
});
70+
71+
describe('Complex and multi-element migrations', () => {
72+
it('should migrate complex object literals with mixed class keys', async () => {
73+
await verifyDeclaration({
74+
before: `<div [ngClass]="{'class1 class2': condition, 'class3': anotherCondition}"></div>`,
75+
after: `<div [class.class1]="condition" [class.class2]="condition" [class.class3]="anotherCondition"></div>`,
76+
});
77+
});
78+
79+
it('should trim and migrate keys with extra whitespace', async () => {
80+
await verifyDeclaration({
81+
before: `<div [ngClass]="{' class1 ': condition, 'class2': anotherCondition}"></div>`,
82+
after: `<div [class.class1]="condition" [class.class2]="anotherCondition"></div>`,
83+
});
84+
});
85+
86+
it('should migrate multiple ngClass bindings across multiple elements', async () => {
87+
await verifyDeclaration({
88+
before: `
89+
<div [ngClass]="{'class1': condition1, 'class2': condition2}"></div>
90+
<div [ngClass]="{'class3': condition3}"></div>`,
91+
after: `
92+
<div [class.class1]="condition1" [class.class2]="condition2"></div>
93+
<div [class.class3]="condition3"></div>`,
94+
});
95+
});
96+
});
97+
98+
describe('Non-migratable and edge cases', () => {
99+
it('should not migrate invalid object literal syntax', async () => {
100+
await verifyDeclarationNoChange(`<div [ngClass]="{foo isActive}"></div>`);
101+
});
102+
103+
it('should not migrate string literal class list', async () => {
104+
await verifyDeclarationNoChange(`<div [ngClass]="'class1 class2'"></div>`);
105+
});
106+
107+
it('should not migrate dynamic variable bindings', async () => {
108+
await verifyDeclarationNoChange(`<div [ngClass]="dynamicClassObject"></div>`);
109+
});
110+
111+
it('should not migrate function call bindings', async () => {
112+
await verifyDeclarationNoChange(`<div [ngClass]="getClasses()"></div>`);
113+
});
114+
});
115+
});
116+
117+
async function verifyDeclaration(testCase: {before: string; after: string}) {
118+
await verify({
119+
before: populateDeclarationTestCase(testCase.before.trim()),
120+
after: populateExpectedResult(testCase.after.trim()),
121+
});
122+
}
123+
124+
async function verifyDeclarationNoChange(beforeAndAfter: string) {
125+
await verifyDeclaration({before: beforeAndAfter, after: beforeAndAfter});
126+
}
127+
128+
async function verify(testCase: {before: string; after: string}) {
129+
const {fs} = await runTsurgeMigration(new NgClassMigration(), [
130+
{
131+
name: absoluteFrom('/app.component.ts'),
132+
isProgramRootFile: true,
133+
contents: testCase.before,
134+
},
135+
]);
136+
137+
const actual = fs.readFile(absoluteFrom('/app.component.ts')).trim();
138+
const expected = testCase.after.trim();
139+
140+
expect(actual).withContext(diffText(expected, actual)).toEqual(expected);
141+
}
142+
143+
function populateDeclarationTestCase(declaration: string): string {
144+
return `import {Component} from '@angular/core';
145+
@Component({ template: \`${declaration}\` })
146+
export class AppComponent {}`;
147+
}
148+
149+
function populateExpectedResult(declaration: string): string {
150+
return `import {Component} from '@angular/core';
151+
@Component({ template: \`${declaration}\` })
152+
export class AppComponent {}`;
153+
}

0 commit comments

Comments
 (0)