From 6b1523bdef7cc88ca119f0f2cc9640d4266c9026 Mon Sep 17 00:00:00 2001 From: Danilo Hoffmann Date: Wed, 9 Oct 2019 11:35:44 +0200 Subject: [PATCH] feat: added move-component schematic --- schematics/src/collection.json | 6 + schematics/src/move-component/factory.js | 156 +++++++++++++ schematics/src/move-component/factory.ts | 185 +++++++++++++++ schematics/src/move-component/factory_spec.ts | 211 ++++++++++++++++++ schematics/src/move-component/schema.d.ts | 17 ++ schematics/src/move-component/schema.json | 33 +++ 6 files changed, 608 insertions(+) create mode 100644 schematics/src/move-component/factory.js create mode 100644 schematics/src/move-component/factory.ts create mode 100644 schematics/src/move-component/factory_spec.ts create mode 100644 schematics/src/move-component/schema.d.ts create mode 100644 schematics/src/move-component/schema.json diff --git a/schematics/src/collection.json b/schematics/src/collection.json index e7465bb11e..279dad08b3 100644 --- a/schematics/src/collection.json +++ b/schematics/src/collection.json @@ -78,6 +78,12 @@ "factory": "./azure-pipeline/factory#createAzurePipeline", "description": "Create Azure Pipeline.", "schema": "./azure-pipeline/schema.json" + }, + "move-component": { + "factory": "./move-component/factory#move", + "description": "Move Component.", + "schema": "./move-component/schema.json", + "hidden": true } } } diff --git a/schematics/src/move-component/factory.js b/schematics/src/move-component/factory.js new file mode 100644 index 0000000000..56daf90c75 --- /dev/null +++ b/schematics/src/move-component/factory.js @@ -0,0 +1,156 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const core_1 = require("@angular-devkit/core"); +const schematics_1 = require("@angular-devkit/schematics"); +const tsquery_1 = require("@phenomnomnominal/tsquery"); +const project_1 = require("@schematics/angular/utility/project"); +const filesystem_1 = require("../utils/filesystem"); +function similarIdx(str1, str2) { + for (let index = 0; index < Math.min(str1.length, str2.length); index++) { + if (str1[index] !== str2[index]) { + return index; + } + } + return 0; +} +function getAbsolutePath(base, rel) { + if (rel.startsWith('..')) { + const myPath = base.split('/'); + myPath.pop(); + const otherPath = rel.split('/').reverse(); + while (otherPath.length && otherPath[otherPath.length - 1] === '..') { + otherPath.pop(); + myPath.pop(); + } + for (const el of otherPath.reverse()) { + myPath.push(el); + } + return myPath.join('/'); + } +} +function getRelativePath(base, abs) { + const basePath = base.split('/'); + basePath.pop(); + const absPath = abs.split('/'); + while (basePath[0] === absPath[0]) { + basePath.shift(); + absPath.shift(); + } + while (basePath.length) { + basePath.pop(); + absPath.splice(0, 0, '..'); + } + return absPath.join('/'); +} +function move(options) { + return host => { + if (!options.project) { + throw new schematics_1.SchematicsException('Option (project) is required.'); + } + if (!options.from) { + throw new schematics_1.SchematicsException('Option (from) is required.'); + } + if (!options.to) { + throw new schematics_1.SchematicsException('Option (to) is required.'); + } + const from = options.from.replace(/\/$/, ''); + host.getDir(from); + const to = options.to.replace(/\/$/, ''); + const renames = []; + const fromName = from.replace(/.*\//, ''); + const fromClassName = core_1.strings.classify(fromName) + 'Component'; + const toName = to.replace(/.*\//, ''); + if (toName.includes('.')) { + throw new schematics_1.SchematicsException(`target must be a directory`); + } + const toClassName = core_1.strings.classify(toName) + 'Component'; + const similarIndex = similarIdx(from, to); + const replacePath = (path) => path + .replace(from.substr(similarIndex), to.substr(similarIndex)) + .replace(fromName + '.component', toName + '.component'); + const replaceImportPath = (file, path) => { + const newPath = replacePath(path); + if (path !== newPath) { + return newPath; + } + else if (path.includes('..')) { + const match = /(\.\.[\w\/\.\-]+)/.exec(path); + if (match) { + const fromRelative = match[0]; + const fromAbsolute = getAbsolutePath(file, fromRelative); + const toAbsolute = replacePath(fromAbsolute); + const potentiallyMovedFile = replacePath(file); + const toRelative = getRelativePath(potentiallyMovedFile, toAbsolute); + return path.replace(fromRelative, toRelative); + } + } + return newPath; + }; + // tslint:disable-next-line:no-console + console.log('moving', options.from, '\n to', options.to); + const sourceRoot = project_1.getProject(host, options.project).sourceRoot; + host.visit(file => { + if (file.startsWith(`/${sourceRoot}/app/`)) { + if (file.includes(from + '/')) { + renames.push([file, replacePath(file)]); + if (fromName !== toName && file.endsWith('.component.ts')) { + const updater = host.beginUpdate(file); + tsquery_1.tsquery(filesystem_1.readIntoSourceFile(host, file), 'Decorator Identifier[name=Component]') + .map(x => x.parent) + .forEach(componentDecorator => { + tsquery_1.tsquery(componentDecorator, 'PropertyAssignment') + .map((pa) => pa.initializer) + .forEach(x => { + updater.remove(x.pos, x.end - x.pos).insertLeft(x.pos, x.getFullText().replace(fromName, toName)); + }); + }); + host.commitUpdate(updater); + } + } + if (file.endsWith('.ts')) { + if (fromClassName !== toClassName) { + const identifiers = tsquery_1.tsquery(filesystem_1.readIntoSourceFile(host, file), `Identifier[name=${fromClassName}]`); + if (identifiers.length) { + const updater = host.beginUpdate(file); + identifiers.forEach(x => updater + .remove(x.pos, x.end - x.pos) + .insertLeft(x.pos, x.getFullText().replace(fromClassName, toClassName))); + host.commitUpdate(updater); + } + } + const imports = tsquery_1.tsquery(filesystem_1.readIntoSourceFile(host, file), file.includes(fromName) ? `ImportDeclaration` : `ImportDeclaration[text=/.*${fromName}.*/]`).filter((x) => file.includes(fromName) || x.getText().includes(`/${fromName}/`)); + if (imports.length) { + const updates = []; + imports.forEach(importDeclaration => { + tsquery_1.tsquery(importDeclaration, 'StringLiteral').forEach(node => { + const replacement = replaceImportPath(file, node.getFullText()); + if (node.getFullText() !== replacement) { + updates.push({ node, replacement }); + } + }); + }); + if (updates.length) { + const updater = host.beginUpdate(file); + updates.forEach(({ node, replacement }) => { + updater.remove(node.pos, node.end - node.pos).insertLeft(node.pos, replacement); + }); + host.commitUpdate(updater); + } + } + } + else if (fromName !== toName && file.endsWith('.html')) { + const content = host.read(file).toString(); + const replacement = content.replace(new RegExp(`(?!.*${fromName}[a-z-]+.*)ish-${fromName}`, 'g'), 'ish-' + toName); + if (content !== replacement) { + host.overwrite(file, replacement); + } + } + } + }); + renames.forEach(([source, target]) => { + host.create(target, host.read(source)); + host.delete(source); + }); + }; +} +exports.move = move; diff --git a/schematics/src/move-component/factory.ts b/schematics/src/move-component/factory.ts new file mode 100644 index 0000000000..eef66db03b --- /dev/null +++ b/schematics/src/move-component/factory.ts @@ -0,0 +1,185 @@ +import { strings } from '@angular-devkit/core'; +import { Rule, SchematicsException } from '@angular-devkit/schematics'; +import { tsquery } from '@phenomnomnominal/tsquery'; +import { getProject } from '@schematics/angular/utility/project'; +import * as ts from 'typescript'; + +import { readIntoSourceFile } from '../utils/filesystem'; + +import { MoveComponentOptionsSchema as Options } from './schema'; + +function similarIdx(str1: string, str2: string) { + for (let index = 0; index < Math.min(str1.length, str2.length); index++) { + if (str1[index] !== str2[index]) { + return index; + } + } + return 0; +} + +function getAbsolutePath(base: string, rel: string): string { + if (rel.startsWith('..')) { + const myPath = base.split('/'); + myPath.pop(); + const otherPath = rel.split('/').reverse(); + while (otherPath.length && otherPath[otherPath.length - 1] === '..') { + otherPath.pop(); + myPath.pop(); + } + for (const el of otherPath.reverse()) { + myPath.push(el); + } + return myPath.join('/'); + } +} + +function getRelativePath(base: string, abs: string): string { + const basePath = base.split('/'); + basePath.pop(); + const absPath = abs.split('/'); + + while (basePath[0] === absPath[0]) { + basePath.shift(); + absPath.shift(); + } + + while (basePath.length) { + basePath.pop(); + absPath.splice(0, 0, '..'); + } + + return absPath.join('/'); +} + +export function move(options: Options): Rule { + return host => { + if (!options.project) { + throw new SchematicsException('Option (project) is required.'); + } + + if (!options.from) { + throw new SchematicsException('Option (from) is required.'); + } + + if (!options.to) { + throw new SchematicsException('Option (to) is required.'); + } + + const from = options.from.replace(/\/$/, ''); + host.getDir(from); + const to = options.to.replace(/\/$/, ''); + + const renames = []; + + const fromName = from.replace(/.*\//, ''); + const fromClassName = strings.classify(fromName) + 'Component'; + const toName = to.replace(/.*\//, ''); + if (toName.includes('.')) { + throw new SchematicsException(`target must be a directory`); + } + + const toClassName = strings.classify(toName) + 'Component'; + + const similarIndex = similarIdx(from, to); + + const replacePath = (path: string) => + path + .replace(from.substr(similarIndex), to.substr(similarIndex)) + .replace(fromName + '.component', toName + '.component'); + + const replaceImportPath = (file: string, path: string) => { + const newPath = replacePath(path); + if (path !== newPath) { + return newPath; + } else if (path.includes('..')) { + const match = /(\.\.[\w\/\.\-]+)/.exec(path); + if (match) { + const fromRelative = match[0]; + const fromAbsolute = getAbsolutePath(file, fromRelative); + const toAbsolute = replacePath(fromAbsolute); + const potentiallyMovedFile = replacePath(file); + const toRelative = getRelativePath(potentiallyMovedFile, toAbsolute); + return path.replace(fromRelative, toRelative); + } + } + return newPath; + }; + // tslint:disable-next-line:no-console + console.log('moving', options.from, '\n to', options.to); + + const sourceRoot = getProject(host, options.project).sourceRoot; + + host.visit(file => { + if (file.startsWith(`/${sourceRoot}/app/`)) { + if (file.includes(from + '/')) { + renames.push([file, replacePath(file)]); + + if (fromName !== toName && file.endsWith('.component.ts')) { + const updater = host.beginUpdate(file); + tsquery(readIntoSourceFile(host, file), 'Decorator Identifier[name=Component]') + .map(x => x.parent) + .forEach(componentDecorator => { + tsquery(componentDecorator, 'PropertyAssignment') + .map((pa: ts.PropertyAssignment) => pa.initializer) + .forEach(x => { + updater.remove(x.pos, x.end - x.pos).insertLeft(x.pos, x.getFullText().replace(fromName, toName)); + }); + }); + host.commitUpdate(updater); + } + } + if (file.endsWith('.ts')) { + if (fromClassName !== toClassName) { + const identifiers = tsquery(readIntoSourceFile(host, file), `Identifier[name=${fromClassName}]`); + if (identifiers.length) { + const updater = host.beginUpdate(file); + identifiers.forEach(x => + updater + .remove(x.pos, x.end - x.pos) + .insertLeft(x.pos, x.getFullText().replace(fromClassName, toClassName)) + ); + host.commitUpdate(updater); + } + } + + const imports = tsquery( + readIntoSourceFile(host, file), + file.includes(fromName) ? `ImportDeclaration` : `ImportDeclaration[text=/.*${fromName}.*/]` + ).filter((x: ts.ImportDeclaration) => file.includes(fromName) || x.getText().includes(`/${fromName}/`)); + if (imports.length) { + const updates: { node: ts.Node; replacement: string }[] = []; + imports.forEach(importDeclaration => { + tsquery(importDeclaration, 'StringLiteral').forEach(node => { + const replacement = replaceImportPath(file, node.getFullText()); + if (node.getFullText() !== replacement) { + updates.push({ node, replacement }); + } + }); + }); + if (updates.length) { + const updater = host.beginUpdate(file); + updates.forEach(({ node, replacement }) => { + updater.remove(node.pos, node.end - node.pos).insertLeft(node.pos, replacement); + }); + host.commitUpdate(updater); + } + } + } else if (fromName !== toName && file.endsWith('.html')) { + const content = host.read(file).toString(); + const replacement = content.replace( + new RegExp(`(?!.*${fromName}[a-z-]+.*)ish-${fromName}`, 'g'), + 'ish-' + toName + ); + if (content !== replacement) { + host.overwrite(file, replacement); + } + } + } + }); + + renames.forEach(([source, target]) => { + host.create(target, host.read(source)); + host.delete(source); + }); + }; +} diff --git a/schematics/src/move-component/factory_spec.ts b/schematics/src/move-component/factory_spec.ts new file mode 100644 index 0000000000..163a38460a --- /dev/null +++ b/schematics/src/move-component/factory_spec.ts @@ -0,0 +1,211 @@ +import { UnitTestTree } from '@angular-devkit/schematics/testing'; + +import { createApplication, createModule, createSchematicRunner } from '../utils/testHelper'; + +describe('Component Schematic', () => { + const schematicRunner = createSchematicRunner(); + + let appTree: UnitTestTree; + beforeEach(async () => { + appTree = await createApplication(schematicRunner) + .pipe(createModule(schematicRunner, { name: 'shared' })) + .toPromise(); + appTree.overwrite('/projects/bar/src/app/app.component.html', ''); + appTree = await schematicRunner + .runSchematicAsync('component', { project: 'bar', name: 'foo/dummy' }, appTree) + .toPromise(); + appTree = await schematicRunner + .runSchematicAsync('component', { project: 'bar', name: 'shared/dummy-two' }, appTree) + .toPromise(); + + appTree.overwrite( + '/projects/bar/src/app/shared/components/dummy-two/dummy-two.component.ts', + `import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { DummyComponent } from '../../../foo/components/dummy/dummy.component'; + +@Component({ + selector: 'ish-dummy-two', + templateUrl: './dummy-two.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DummyTwoComponent {} +` + ); + }); + + it('should be created', () => { + expect(appTree.files.filter(f => f.endsWith('component.ts'))).toMatchInlineSnapshot(` + Array [ + "/projects/bar/src/app/app.component.ts", + "/projects/bar/src/app/shared/components/dummy-two/dummy-two.component.ts", + "/projects/bar/src/app/foo/components/dummy/dummy.component.ts", + ] + `); + expect(appTree.readContent('/projects/bar/src/app/shared/components/dummy-two/dummy-two.component.ts')) + .toMatchInlineSnapshot(` + "import { ChangeDetectionStrategy, Component } from '@angular/core'; + import { DummyComponent } from '../../../foo/components/dummy/dummy.component'; + + @Component({ + selector: 'ish-dummy-two', + templateUrl: './dummy-two.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + export class DummyTwoComponent {} + " + `); + }); + + it('should move component from a to b', async () => { + appTree = await schematicRunner + .runSchematicAsync('move-component', { project: 'bar', from: 'foo/components/dummy', to: 'foo' }, appTree) + .toPromise(); + + expect(appTree.files.filter(x => x.includes('/src/app/'))).toMatchInlineSnapshot(` + Array [ + "/projects/bar/src/app/app-routing.module.ts", + "/projects/bar/src/app/app.module.ts", + "/projects/bar/src/app/app.component.css", + "/projects/bar/src/app/app.component.html", + "/projects/bar/src/app/app.component.spec.ts", + "/projects/bar/src/app/app.component.ts", + "/projects/bar/src/app/shared/shared.module.ts", + "/projects/bar/src/app/shared/components/dummy-two/dummy-two.component.ts", + "/projects/bar/src/app/shared/components/dummy-two/dummy-two.component.html", + "/projects/bar/src/app/shared/components/dummy-two/dummy-two.component.spec.ts", + "/projects/bar/src/app/foo/foo.component.ts", + "/projects/bar/src/app/foo/foo.component.html", + "/projects/bar/src/app/foo/foo.component.spec.ts", + ] + `); + }); + + it('should rename component everywhere when moving', async () => { + appTree = await schematicRunner + .runSchematicAsync('move-component', { project: 'bar', from: 'foo/components/dummy', to: 'foo' }, appTree) + .toPromise(); + + expect(appTree.readContent('/projects/bar/src/app/app.module.ts')).toMatchInlineSnapshot(` + "import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + import { AppRoutingModule } from './app-routing.module'; + import { AppComponent } from './app.component'; + import { FooComponent } from './foo/foo.component'; + + @NgModule({ + declarations: [ + AppComponent, + FooComponent + ], + imports: [ + BrowserModule, + AppRoutingModule + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + " + `); + + expect(appTree.readContent('/projects/bar/src/app/app.component.html')).toMatchInlineSnapshot( + `""` + ); + + expect(appTree.readContent('/projects/bar/src/app/foo/foo.component.spec.ts')).toMatchInlineSnapshot(` + "import { ComponentFixture, TestBed, async } from '@angular/core/testing'; + + import { FooComponent } from './foo.component'; + + describe('DummyComponent', () => { + let component: FooComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [FooComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(FooComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + }); + " + `); + + expect(appTree.readContent('/projects/bar/src/app/foo/foo.component.ts')).toMatchInlineSnapshot(` + "import { ChangeDetectionStrategy, Component } from '@angular/core'; + + @Component({ + selector: 'ish-foo', + templateUrl: './foo.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + export class FooComponent {} + " + `); + }); + + it.each([ + { from: 'shared/components/dummy-two', to: 'shared/foo' }, + { from: 'src/app/shared/components/dummy-two', to: 'src/app/shared/foo' }, + ])('should rename component everywhere when moving %j', async ({ from, to }) => { + appTree = await schematicRunner + .runSchematicAsync('move-component', { project: 'bar', from, to }, appTree) + .toPromise(); + + expect(appTree.readContent('/projects/bar/src/app/shared/shared.module.ts')).toMatchInlineSnapshot(` + "import { NgModule } from '@angular/core'; + import { FooComponent } from './foo/foo.component'; + + @NgModule({ + imports: [], + declarations: [FooComponent], + exports: [], + entryComponents: [] + }) + export class SharedModule { } + " + `); + + expect(appTree.files.filter(f => f.endsWith('component.ts'))).toMatchInlineSnapshot(` + Array [ + "/projects/bar/src/app/app.component.ts", + "/projects/bar/src/app/shared/foo/foo.component.ts", + "/projects/bar/src/app/foo/components/dummy/dummy.component.ts", + ] + `); + + expect(appTree.readContent('/projects/bar/src/app/shared/foo/foo.component.ts')).toMatchInlineSnapshot(` + "import { ChangeDetectionStrategy, Component } from '@angular/core'; + import { DummyComponent } from '../../foo/components/dummy/dummy.component'; + + @Component({ + selector: 'ish-foo', + templateUrl: './foo.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + }) + export class FooComponent {} + " + `); + + expect(appTree.readContent('/projects/bar/src/app/shared/foo/foo.component.spec.ts')).toContain( + `import { FooComponent } from './foo.component';` + ); + + expect(appTree.readContent('/projects/bar/src/app/foo/components/dummy/dummy.component.spec.ts')).toContain( + `import { DummyComponent } from './dummy.component';` + ); + }); +}); diff --git a/schematics/src/move-component/schema.d.ts b/schematics/src/move-component/schema.d.ts new file mode 100644 index 0000000000..8e8b71bc84 --- /dev/null +++ b/schematics/src/move-component/schema.d.ts @@ -0,0 +1,17 @@ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export interface MoveComponentOptionsSchema { + project?: string; + /** + * The folder of the component source. + */ + from?: string; + /** + * The target folder of the component. + */ + to?: string; +} diff --git a/schematics/src/move-component/schema.json b/schematics/src/move-component/schema.json new file mode 100644 index 0000000000..f09ebc1703 --- /dev/null +++ b/schematics/src/move-component/schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "MoveComponent", + "title": "Move Component Options Schema", + "type": "object", + "properties": { + "project": { + "type": "string", + "$default": { + "$source": "projectName" + }, + "visible": false + }, + "from": { + "type": "string", + "description": "The folder of the component source.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What source folder has the component? (relative to src/app/)" + }, + "to": { + "type": "string", + "description": "The target folder of the component.", + "$default": { + "$source": "argv", + "index": 1 + }, + "x-prompt": "What target folder would you like to use for the component? (relative to src/app/)" + } + } +}