@@ -25,7 +25,7 @@ describe('NgClass migration', () => {
2525 return runner . runSchematic ( 'ngclass-to-class' , options , tree ) ;
2626 }
2727
28- const collectionJsonPath = resolve ( '../collection.json' ) ;
28+ const collectionJsonPath = resolve ( '../../ collection.json' ) ;
2929 beforeEach ( ( ) => {
3030 runner = new SchematicTestRunner ( 'test' , collectionJsonPath ) ;
3131 host = new TempScopedNodeJsSyncHost ( ) ;
@@ -126,7 +126,6 @@ describe('NgClass migration', () => {
126126
127127 const content = tree . readContent ( '/app.component.ts' ) ;
128128
129- // The object literal binding should be migrated.
130129 expect ( content ) . toContain ( '[class.active]="isActive"' ) ;
131130 // The array binding should remain as [ngClass].
132131 expect ( content ) . toContain ( "[ngClass]=\"['class-a', condition ? 'class-b' : '']\"" ) ;
@@ -505,7 +504,10 @@ describe('NgClass migration', () => {
505504 } ) ;
506505
507506 describe ( 'Complex and multi-element migrations' , ( ) => {
508- it ( 'should migrate complex object literals with mixed class keys to [class] binding' , async ( ) => {
507+ it ( 'should not migrate a space-separated key mixed with a regular key to [class] binding by default' , async ( ) => {
508+ // Regression test for https://github.com/angular/angular/issues/69833 — [class]="{...}"
509+ // object syntax does not support space-separated key names, so without
510+ // `migrateSpaceSeparatedKey` the binding must be left as [ngClass].
509511 writeFile (
510512 '/app.component.ts' ,
511513 `
@@ -525,8 +527,9 @@ describe('NgClass migration', () => {
525527 const content = tree . readContent ( '/app.component.ts' ) ;
526528
527529 expect ( content ) . toContain (
528- `<div [class ]="{'class1 class2': condition, 'class3': anotherCondition}"></div>` ,
530+ `<div [ngClass ]="{'class1 class2': condition, 'class3': anotherCondition}"></div>` ,
529531 ) ;
532+ expect ( content ) . not . toContain ( '[class]=' ) ;
530533 } ) ;
531534
532535 it ( 'should migrate keys with extra whitespace for multiple conditions' , async ( ) => {
@@ -1039,7 +1042,7 @@ describe('migrateSpaceSeparatedKey option', () => {
10391042 return runner . runSchematic ( 'ngclass-to-class' , options , tree ) ;
10401043 }
10411044
1042- const collectionJsonPath = resolve ( '../collection.json' ) ;
1045+ const collectionJsonPath = resolve ( '../../ collection.json' ) ;
10431046 beforeEach ( ( ) => {
10441047 runner = new SchematicTestRunner ( 'test' , collectionJsonPath ) ;
10451048 host = new TempScopedNodeJsSyncHost ( ) ;
@@ -1075,4 +1078,224 @@ describe('migrateSpaceSeparatedKey option', () => {
10751078
10761079 expect ( content ) . toContain ( `<div [class.class1]="condition" [class.class2]="condition"></div>` ) ;
10771080 } ) ;
1081+
1082+ it ( 'should expand mixed space-separated and regular keys individually' , async ( ) => {
1083+ // Regression test for https://github.com/angular/angular/issues/69833
1084+ // The object has one key with spaces ('class1 class2') and one plain key ('class3'),
1085+ // each with a different condition. The [class]="..." fallback cannot represent
1086+ // space-separated key names, so every entry must be emitted as [class.X]="condition".
1087+ writeFile (
1088+ '/app.component.ts' ,
1089+ `
1090+ import {Component} from '@angular/core';
1091+ import {NgClass} from '@angular/common';
1092+ @Component({
1093+ imports: [NgClass],
1094+ template: \`
1095+ <div [ngClass]="{'class1 class2': condition, 'class3': condition2}"></div>
1096+ \` })
1097+ export class Cmp {}
1098+ ` ,
1099+ ) ;
1100+
1101+ await runMigration ( { migrateSpaceSeparatedKey : true } ) ;
1102+
1103+ const content = tree . readContent ( '/app.component.ts' ) ;
1104+
1105+ expect ( content ) . toContain (
1106+ `<div [class.class1]="condition" [class.class2]="condition" [class.class3]="condition2"></div>` ,
1107+ ) ;
1108+ } ) ;
1109+
1110+ it ( 'should not expand when a space-separated key is mixed with an empty-string key' , async ( ) => {
1111+ // Expanding every binding as [class.${key}] would produce the invalid `[class.]` binding
1112+ // for the empty key, so the whole binding must be left unchanged instead.
1113+ writeFile (
1114+ '/app.component.ts' ,
1115+ `
1116+ import {Component} from '@angular/core';
1117+ import {NgClass} from '@angular/common';
1118+ @Component({
1119+ imports: [NgClass],
1120+ template: \`
1121+ <div [ngClass]="{'class1 class2': condition, '': condition2}"></div>
1122+ \` })
1123+ export class Cmp {}
1124+ ` ,
1125+ ) ;
1126+
1127+ await runMigration ( { migrateSpaceSeparatedKey : true } ) ;
1128+
1129+ const content = tree . readContent ( '/app.component.ts' ) ;
1130+
1131+ expect ( content ) . toContain (
1132+ `<div [ngClass]="{'class1 class2': condition, '': condition2}"></div>` ,
1133+ ) ;
1134+ expect ( content ) . not . toContain ( '[class.]' ) ;
1135+ } ) ;
1136+
1137+ it ( 'should not expand when a space-separated key expansion produces a duplicate class name' , async ( ) => {
1138+ // 'class1 class2': first expands to class1/class2, and the separate 'class1' key would
1139+ // collide with the expanded class1 binding, producing two conflicting [class.class1]
1140+ // bindings on the same element. The binding must be left unchanged instead.
1141+ writeFile (
1142+ '/app.component.ts' ,
1143+ `
1144+ import {Component} from '@angular/core';
1145+ import {NgClass} from '@angular/common';
1146+ @Component({
1147+ imports: [NgClass],
1148+ template: \`
1149+ <div [ngClass]="{'class1 class2': first, class1: second}"></div>
1150+ \` })
1151+ export class Cmp {}
1152+ ` ,
1153+ ) ;
1154+
1155+ await runMigration ( { migrateSpaceSeparatedKey : true } ) ;
1156+
1157+ const content = tree . readContent ( '/app.component.ts' ) ;
1158+
1159+ expect ( content ) . toContain ( `<div [ngClass]="{'class1 class2': first, class1: second}"></div>` ) ;
1160+ } ) ;
1161+
1162+ it ( 'should not expand a space-separated key whose expanded class name contains a dot' , async ( ) => {
1163+ // `[class.a.b]` is parsed by Angular as binding to property `a`, silently discarding
1164+ // everything after the first dot — expanding 'a.b c' would produce a binding that
1165+ // silently applies the wrong class. The whole binding must be left unchanged instead.
1166+ writeFile (
1167+ '/app.component.ts' ,
1168+ `
1169+ import {Component} from '@angular/core';
1170+ import {NgClass} from '@angular/common';
1171+ @Component({
1172+ imports: [NgClass],
1173+ template: \`
1174+ <div [ngClass]="{'a.b c': condition, 'd': condition2}"></div>
1175+ \` })
1176+ export class Cmp {}
1177+ ` ,
1178+ ) ;
1179+
1180+ await runMigration ( { migrateSpaceSeparatedKey : true } ) ;
1181+
1182+ const content = tree . readContent ( '/app.component.ts' ) ;
1183+
1184+ expect ( content ) . toContain ( `<div [ngClass]="{'a.b c': condition, 'd': condition2}"></div>` ) ;
1185+ expect ( content ) . not . toContain ( '[class.a' ) ;
1186+ } ) ;
1187+
1188+ it ( 'should treat a tab as a class-name separator' , async ( ) => {
1189+ writeFile (
1190+ '/app.component.ts' ,
1191+ `
1192+ import {Component} from '@angular/core';
1193+ import {NgClass} from '@angular/common';
1194+ @Component({
1195+ imports: [NgClass],
1196+ template: \`
1197+ <div [ngClass]="{'class1\\tclass2': condition}"></div>
1198+ \` })
1199+ export class Cmp {}
1200+ ` ,
1201+ ) ;
1202+
1203+ await runMigration ( { migrateSpaceSeparatedKey : true } ) ;
1204+
1205+ const content = tree . readContent ( '/app.component.ts' ) ;
1206+
1207+ expect ( content ) . toContain ( `<div [class.class1]="condition" [class.class2]="condition"></div>` ) ;
1208+ } ) ;
1209+
1210+ it ( 'should treat a newline as a class-name separator' , async ( ) => {
1211+ writeFile (
1212+ '/app.component.ts' ,
1213+ `
1214+ import {Component} from '@angular/core';
1215+ import {NgClass} from '@angular/common';
1216+ @Component({
1217+ imports: [NgClass],
1218+ template: \`
1219+ <div [ngClass]="{'class1\\nclass2': condition}"></div>
1220+ \` })
1221+ export class Cmp {}
1222+ ` ,
1223+ ) ;
1224+
1225+ await runMigration ( { migrateSpaceSeparatedKey : true } ) ;
1226+
1227+ const content = tree . readContent ( '/app.component.ts' ) ;
1228+
1229+ expect ( content ) . toContain ( `<div [class.class1]="condition" [class.class2]="condition"></div>` ) ;
1230+ } ) ;
1231+
1232+ it ( 'should not expand when a shorthand property collides with an expanded space-separated key' , async ( ) => {
1233+ // The shorthand `class1` binding and the `class1` produced by splitting
1234+ // 'class1 class2' would collide, producing two conflicting [class.class1] bindings.
1235+ writeFile (
1236+ '/app.component.ts' ,
1237+ `
1238+ import {Component} from '@angular/core';
1239+ import {NgClass} from '@angular/common';
1240+ @Component({
1241+ imports: [NgClass],
1242+ template: \`
1243+ <div [ngClass]="{class1, 'class1 class2': condition}"></div>
1244+ \` })
1245+ export class Cmp {}
1246+ ` ,
1247+ ) ;
1248+
1249+ await runMigration ( { migrateSpaceSeparatedKey : true } ) ;
1250+
1251+ const content = tree . readContent ( '/app.component.ts' ) ;
1252+
1253+ expect ( content ) . toContain ( `<div [ngClass]="{class1, 'class1 class2': condition}"></div>` ) ;
1254+ } ) ;
1255+
1256+ it ( 'should expand a space-separated key mixed with a numeric-looking class name' , async ( ) => {
1257+ writeFile (
1258+ '/app.component.ts' ,
1259+ `
1260+ import {Component} from '@angular/core';
1261+ import {NgClass} from '@angular/common';
1262+ @Component({
1263+ imports: [NgClass],
1264+ template: \`
1265+ <div [ngClass]="{'1foo bar': condition}"></div>
1266+ \` })
1267+ export class Cmp {}
1268+ ` ,
1269+ ) ;
1270+
1271+ await runMigration ( { migrateSpaceSeparatedKey : true } ) ;
1272+
1273+ const content = tree . readContent ( '/app.component.ts' ) ;
1274+
1275+ expect ( content ) . toContain ( `<div [class.1foo]="condition" [class.bar]="condition"></div>` ) ;
1276+ } ) ;
1277+
1278+ it ( 'should not migrate when a computed property key is mixed with a space-separated key' , async ( ) => {
1279+ writeFile (
1280+ '/app.component.ts' ,
1281+ `
1282+ import {Component} from '@angular/core';
1283+ import {NgClass} from '@angular/common';
1284+ @Component({
1285+ imports: [NgClass],
1286+ template: \`
1287+ <div [ngClass]="{[dynamicKey]: condition, 'class1 class2': condition2}"></div>
1288+ \` })
1289+ export class Cmp {}
1290+ ` ,
1291+ ) ;
1292+
1293+ await runMigration ( { migrateSpaceSeparatedKey : true } ) ;
1294+
1295+ const content = tree . readContent ( '/app.component.ts' ) ;
1296+
1297+ expect ( content ) . toContain (
1298+ `<div [ngClass]="{[dynamicKey]: condition, 'class1 class2': condition2}"></div>` ,
1299+ ) ;
1300+ } ) ;
10781301} ) ;
0 commit comments