From 233730032d5a1b87851f8b81480b33feb2aed7be Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Mon, 7 Apr 2025 12:36:07 -0400 Subject: [PATCH] feat(@schematics/angular): add update migration to keep previous style guide generation behavior When updating to Angular v20 via `ng update`, a migration will be executed that will add schematic generation (`ng generate`) defaults to the workspace. These defaults will ensure that existing projects will continue to generate files as done in previous versions of the Angular CLI. All new projects (via `ng new`) or projects that do not explicitly contain these options in their workspace will use the updated style guide naming behavior. The option values for the `schematics` field are as follows: ``` { '@schematics/angular:component': { type: 'component' }, '@schematics/angular:directive': { type: 'directive' }, '@schematics/angular:service': { type: 'service' }, '@schematics/angular:guard': { typeSeparator: '.' }, '@schematics/angular:interceptor': { typeSeparator: '.' }, '@schematics/angular:module': { typeSeparator: '.' }, '@schematics/angular:pipe': { typeSeparator: '.' }, '@schematics/angular:resolver': { typeSeparator: '.' }, } ``` --- .../migrations/migration-collection.json | 5 + .../previous-style-guide/migration.ts | 51 +++++++ .../previous-style-guide/migration_spec.ts | 141 ++++++++++++++++++ 3 files changed, 197 insertions(+) create mode 100644 packages/schematics/angular/migrations/previous-style-guide/migration.ts create mode 100644 packages/schematics/angular/migrations/previous-style-guide/migration_spec.ts diff --git a/packages/schematics/angular/migrations/migration-collection.json b/packages/schematics/angular/migrations/migration-collection.json index a70c930290dd..659dd48728cd 100644 --- a/packages/schematics/angular/migrations/migration-collection.json +++ b/packages/schematics/angular/migrations/migration-collection.json @@ -15,6 +15,11 @@ "factory": "./update-module-resolution/migration", "description": "Update 'moduleResolution' to 'bundler' in TypeScript configurations. You can read more about this, here: https://www.typescriptlang.org/tsconfig/#moduleResolution" }, + "previous-style-guide": { + "version": "20.0.0", + "factory": "./previous-style-guide/migration", + "description": "Update workspace generation defaults to maintain previous style guide behavior." + }, "use-application-builder": { "version": "20.0.0", "factory": "./use-application-builder/migration", diff --git a/packages/schematics/angular/migrations/previous-style-guide/migration.ts b/packages/schematics/angular/migrations/previous-style-guide/migration.ts new file mode 100644 index 000000000000..1590948b243d --- /dev/null +++ b/packages/schematics/angular/migrations/previous-style-guide/migration.ts @@ -0,0 +1,51 @@ +/** + * @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.dev/license + */ + +import type { Rule } from '@angular-devkit/schematics'; +import { updateWorkspace } from '../../utility/workspace'; + +const TYPE_SCHEMATICS = ['component', 'directive', 'service'] as const; + +const SEPARATOR_SCHEMATICS = ['guard', 'interceptor', 'module', 'pipe', 'resolver'] as const; + +export default function (): Rule { + return updateWorkspace((workspace) => { + let schematicsDefaults = workspace.extensions['schematics']; + + // Ensure "schematics" field is an object + if ( + !schematicsDefaults || + typeof schematicsDefaults !== 'object' || + Array.isArray(schematicsDefaults) + ) { + schematicsDefaults = workspace.extensions['schematics'] = {}; + } + + // Add "type" value for each schematic to continue generating a type suffix. + // New default is an empty type value. + for (const schematicName of TYPE_SCHEMATICS) { + const schematic = (schematicsDefaults[`@schematics/angular:${schematicName}`] ??= {}); + if (typeof schematic === 'object' && !Array.isArray(schematic) && !('type' in schematic)) { + schematic['type'] = schematicName; + } + } + + // Add "typeSeparator" value for each schematic to continue generating "." before type. + // New default is an "-" type value. + for (const schematicName of SEPARATOR_SCHEMATICS) { + const schematic = (schematicsDefaults[`@schematics/angular:${schematicName}`] ??= {}); + if ( + typeof schematic === 'object' && + !Array.isArray(schematic) && + !('typeSeparator' in schematic) + ) { + schematic['typeSeparator'] = '.'; + } + } + }); +} diff --git a/packages/schematics/angular/migrations/previous-style-guide/migration_spec.ts b/packages/schematics/angular/migrations/previous-style-guide/migration_spec.ts new file mode 100644 index 000000000000..342da3910e74 --- /dev/null +++ b/packages/schematics/angular/migrations/previous-style-guide/migration_spec.ts @@ -0,0 +1,141 @@ +/** + * @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.dev/license + */ + +import { EmptyTree } from '@angular-devkit/schematics'; +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; +import { ProjectType, WorkspaceSchema } from '../../utility/workspace-models'; + +function createWorkSpaceConfig(tree: UnitTestTree, initialSchematicsValue?: unknown) { + const angularConfig: WorkspaceSchema = { + version: 1, + projects: { + app: { + root: '/project/lib', + sourceRoot: '/project/app/src', + projectType: ProjectType.Application, + prefix: 'app', + architect: {}, + }, + }, + }; + + if (initialSchematicsValue !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (angularConfig as any).schematics = initialSchematicsValue; + } + + tree.create('/angular.json', JSON.stringify(angularConfig, undefined, 2)); +} + +describe(`Migration to update 'angular.json'.`, () => { + const schematicName = 'previous-style-guide'; + const schematicRunner = new SchematicTestRunner( + 'migrations', + require.resolve('../migration-collection.json'), + ); + + let tree: UnitTestTree; + beforeEach(() => { + tree = new UnitTestTree(new EmptyTree()); + }); + + it(`should add defaults if no "schematics" workspace field is present`, async () => { + createWorkSpaceConfig(tree); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const { schematics } = JSON.parse(newTree.readContent('/angular.json')); + + expect(schematics).toEqual({ + '@schematics/angular:component': { type: 'component' }, + '@schematics/angular:directive': { type: 'directive' }, + '@schematics/angular:service': { type: 'service' }, + '@schematics/angular:guard': { typeSeparator: '.' }, + '@schematics/angular:interceptor': { typeSeparator: '.' }, + '@schematics/angular:module': { typeSeparator: '.' }, + '@schematics/angular:pipe': { typeSeparator: '.' }, + '@schematics/angular:resolver': { typeSeparator: '.' }, + }); + }); + + it(`should add defaults if empty "schematics" workspace field is present`, async () => { + createWorkSpaceConfig(tree, {}); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const { schematics } = JSON.parse(newTree.readContent('/angular.json')); + + expect(schematics).toEqual({ + '@schematics/angular:component': { type: 'component' }, + '@schematics/angular:directive': { type: 'directive' }, + '@schematics/angular:service': { type: 'service' }, + '@schematics/angular:guard': { typeSeparator: '.' }, + '@schematics/angular:interceptor': { typeSeparator: '.' }, + '@schematics/angular:module': { typeSeparator: '.' }, + '@schematics/angular:pipe': { typeSeparator: '.' }, + '@schematics/angular:resolver': { typeSeparator: '.' }, + }); + }); + + it(`should add defaults if invalid "schematics" workspace field is present`, async () => { + createWorkSpaceConfig(tree, 10); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const { schematics } = JSON.parse(newTree.readContent('/angular.json')); + + expect(schematics).toEqual({ + '@schematics/angular:component': { type: 'component' }, + '@schematics/angular:directive': { type: 'directive' }, + '@schematics/angular:service': { type: 'service' }, + '@schematics/angular:guard': { typeSeparator: '.' }, + '@schematics/angular:interceptor': { typeSeparator: '.' }, + '@schematics/angular:module': { typeSeparator: '.' }, + '@schematics/angular:pipe': { typeSeparator: '.' }, + '@schematics/angular:resolver': { typeSeparator: '.' }, + }); + }); + + it(`should add defaults if existing unrelated "schematics" workspace defaults are present`, async () => { + createWorkSpaceConfig(tree, { + '@schematics/angular:component': { style: 'scss' }, + }); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const { schematics } = JSON.parse(newTree.readContent('/angular.json')); + + expect(schematics).toEqual({ + '@schematics/angular:component': { style: 'scss', type: 'component' }, + '@schematics/angular:directive': { type: 'directive' }, + '@schematics/angular:service': { type: 'service' }, + '@schematics/angular:guard': { typeSeparator: '.' }, + '@schematics/angular:interceptor': { typeSeparator: '.' }, + '@schematics/angular:module': { typeSeparator: '.' }, + '@schematics/angular:pipe': { typeSeparator: '.' }, + '@schematics/angular:resolver': { typeSeparator: '.' }, + }); + }); + + it(`should not overwrite defaults if existing "schematics" workspace defaults are present`, async () => { + createWorkSpaceConfig(tree, { + '@schematics/angular:component': { type: 'example' }, + '@schematics/angular:guard': { typeSeparator: '-' }, + }); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const { schematics } = JSON.parse(newTree.readContent('/angular.json')); + + expect(schematics).toEqual({ + '@schematics/angular:component': { type: 'example' }, + '@schematics/angular:directive': { type: 'directive' }, + '@schematics/angular:service': { type: 'service' }, + '@schematics/angular:guard': { typeSeparator: '-' }, + '@schematics/angular:interceptor': { typeSeparator: '.' }, + '@schematics/angular:module': { typeSeparator: '.' }, + '@schematics/angular:pipe': { typeSeparator: '.' }, + '@schematics/angular:resolver': { typeSeparator: '.' }, + }); + }); +});