diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 72c7b8926a..ba1918918d 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -3699,6 +3699,14 @@ "isExternal": false, "disableCollapsible": false }, + { + "id": "directive", + "path": "/packages/angular/generators/directive", + "name": "directive", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, { "id": "init", "path": "/packages/angular/generators/init", diff --git a/docs/generated/manifests/packages.json b/docs/generated/manifests/packages.json index d5eb688cbe..bdfa5c4905 100644 --- a/docs/generated/manifests/packages.json +++ b/docs/generated/manifests/packages.json @@ -189,6 +189,15 @@ "path": "/packages/angular/generators/convert-tslint-to-eslint", "type": "generator" }, + "/packages/angular/generators/directive": { + "description": "Generate an Angular directive.", + "file": "generated/packages/angular/generators/directive.json", + "hidden": false, + "name": "directive", + "originalFilePath": "/packages/angular/src/generators/directive/schema.json", + "path": "/packages/angular/generators/directive", + "type": "generator" + }, "/packages/angular/generators/init": { "description": "Initializes the `@nrwl/angular` plugin.", "file": "generated/packages/angular/generators/init.json", diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 908ed6bf6c..9bda270fdd 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -183,6 +183,15 @@ "path": "angular/generators/convert-tslint-to-eslint", "type": "generator" }, + { + "description": "Generate an Angular directive.", + "file": "generated/packages/angular/generators/directive.json", + "hidden": false, + "name": "directive", + "originalFilePath": "/packages/angular/src/generators/directive/schema.json", + "path": "angular/generators/directive", + "type": "generator" + }, { "description": "Initializes the `@nrwl/angular` plugin.", "file": "generated/packages/angular/generators/init.json", diff --git a/docs/generated/packages/angular/generators/directive.json b/docs/generated/packages/angular/generators/directive.json new file mode 100644 index 0000000000..fa34b1dd06 --- /dev/null +++ b/docs/generated/packages/angular/generators/directive.json @@ -0,0 +1,90 @@ +{ + "name": "directive", + "factory": "./src/generators/directive/directive", + "schema": { + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "GeneratorAngularDirective", + "cli": "nx", + "title": "Nx Angular Directive Options Schema", + "type": "object", + "description": "Creates a new, generic directive definition in the given project.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the new directive.", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What name would you like to use for the directive?" + }, + "path": { + "type": "string", + "format": "path", + "$default": { "$source": "workingDirectory" }, + "description": "The path at which to create the interface that defines the directive, relative to the workspace root.", + "visible": false + }, + "project": { + "type": "string", + "description": "The name of the project.", + "x-dropdown": "projects" + }, + "prefix": { + "type": "string", + "description": "A prefix to apply to generated selectors.", + "alias": "p", + "oneOf": [ + { "maxLength": 0 }, + { "minLength": 1, "format": "html-selector" } + ] + }, + "skipTests": { + "type": "boolean", + "description": "Do not create \"spec.ts\" test files for the new class.", + "default": false + }, + "skipImport": { + "type": "boolean", + "description": "Do not import this directive into the owning NgModule.", + "default": false + }, + "selector": { + "type": "string", + "format": "html-selector", + "description": "The HTML selector to use for this directive." + }, + "standalone": { + "description": "Whether the generated directive is standalone.", + "type": "boolean", + "default": false + }, + "flat": { + "type": "boolean", + "description": "When true (the default), creates the new files at the top level of the current project.", + "default": true + }, + "module": { + "type": "string", + "description": "The filename of the declaring NgModule.", + "alias": "m" + }, + "export": { + "type": "boolean", + "default": false, + "description": "The declaring NgModule exports this directive." + }, + "skipFormat": { + "type": "boolean", + "default": false, + "description": "Skip formatting of files." + } + }, + "required": ["name", "project"], + "presets": [] + }, + "aliases": ["d"], + "description": "Generate an Angular directive.", + "implementation": "/packages/angular/src/generators/directive/directive.ts", + "hidden": false, + "path": "/packages/angular/src/generators/directive/schema.json", + "type": "generator" +} diff --git a/e2e/workspace-create/src/create-nx-workspace.test.ts b/e2e/workspace-create/src/create-nx-workspace.test.ts index 64228db9ea..76fe6a4b90 100644 --- a/e2e/workspace-create/src/create-nx-workspace.test.ts +++ b/e2e/workspace-create/src/create-nx-workspace.test.ts @@ -341,7 +341,7 @@ describe('create-nx-workspace', () => { process.env.SELECTED_PM = packageManager; }); - it('should store package manager preference for angular cli', () => { + it('should store package manager preference for angular', () => { const wsName = uniq('pm'); const appName = uniq('app'); diff --git a/packages/angular/generators.json b/packages/angular/generators.json index 904f7e2d0e..a99ad9bbb8 100644 --- a/packages/angular/generators.json +++ b/packages/angular/generators.json @@ -195,6 +195,12 @@ "schema": "./src/generators/convert-tslint-to-eslint/schema.json", "description": "Converts a project from TSLint to ESLint." }, + "directive": { + "factory": "./src/generators/directive/directive", + "schema": "./src/generators/directive/schema.json", + "aliases": ["d"], + "description": "Generate an Angular directive." + }, "init": { "factory": "./src/generators/init/init", "schema": "./src/generators/init/schema.json", diff --git a/packages/angular/generators.ts b/packages/angular/generators.ts index 33b4b30b60..7f771db4b0 100644 --- a/packages/angular/generators.ts +++ b/packages/angular/generators.ts @@ -4,6 +4,7 @@ export * from './src/generators/component-cypress-spec/component-cypress-spec'; export * from './src/generators/component-story/component-story'; export * from './src/generators/component/component'; export * from './src/generators/convert-tslint-to-eslint/convert-tslint-to-eslint'; +export * from './src/generators/directive/directive'; export * from './src/generators/host/host'; export * from './src/generators/init/init'; export * from './src/generators/library-secondary-entry-point/library-secondary-entry-point'; diff --git a/packages/angular/src/generators/component/component.ts b/packages/angular/src/generators/component/component.ts index f559726e92..7b1e3d73a5 100644 --- a/packages/angular/src/generators/component/component.ts +++ b/packages/angular/src/generators/component/component.ts @@ -1,17 +1,11 @@ import type { Tree } from '@nrwl/devkit'; -import { - formatFiles, - normalizePath, - readNxJson, - readProjectConfiguration, - stripIndents, -} from '@nrwl/devkit'; -import { pathStartsWith } from '../utils/path'; +import { formatFiles, stripIndents } from '@nrwl/devkit'; import { exportComponentInEntryPoint } from './lib/component'; import { normalizeOptions } from './lib/normalize-options'; -import type { NormalizedSchema, Schema } from './schema'; +import type { Schema } from './schema'; import { getInstalledAngularVersionInfo } from '../utils/version-utils'; import { lt } from 'semver'; +import { checkPathUnderProjectRoot } from '../utils/path'; export async function componentGenerator(tree: Tree, rawOptions: Schema) { const installedAngularVersionInfo = getInstalledAngularVersionInfo(tree); @@ -27,7 +21,7 @@ export async function componentGenerator(tree: Tree, rawOptions: Schema) { const options = await normalizeOptions(tree, rawOptions); const { projectSourceRoot, ...schematicOptions } = options; - checkPathUnderProjectRoot(tree, options); + checkPathUnderProjectRoot(tree, options.project, options.path); const { wrapAngularDevkitSchematic } = require('@nrwl/devkit/ngcli-adapter'); const angularComponentSchematic = wrapAngularDevkitSchematic( @@ -41,25 +35,4 @@ export async function componentGenerator(tree: Tree, rawOptions: Schema) { await formatFiles(tree); } -function checkPathUnderProjectRoot(tree: Tree, schema: NormalizedSchema): void { - if (!schema.path) { - return; - } - - const project = schema.project ?? readNxJson(tree).defaultProject; - const { root } = readProjectConfiguration(tree, project); - - let pathToComponent = normalizePath(schema.path); - pathToComponent = pathToComponent.startsWith('/') - ? pathToComponent.slice(1) - : pathToComponent; - - if (!pathStartsWith(pathToComponent, root)) { - throw new Error( - `The path provided for the component (${schema.path}) does not exist under the project root (${root}). ` + - `Please make sure to provide a path that exists under the project root.` - ); - } -} - export default componentGenerator; diff --git a/packages/angular/src/generators/directive/__snapshots__/directive.spec.ts.snap b/packages/angular/src/generators/directive/__snapshots__/directive.spec.ts.snap new file mode 100644 index 0000000000..151a4a8371 --- /dev/null +++ b/packages/angular/src/generators/directive/__snapshots__/directive.spec.ts.snap @@ -0,0 +1,247 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`directive generator should export the directive correctly when flat=false and path is nested deeper 1`] = ` +"import { Directive } from '@angular/core'; + +@Directive({ + selector: '[projTest]' +}) +export class TestDirective { + + constructor() { } + +} +" +`; + +exports[`directive generator should export the directive correctly when flat=false and path is nested deeper 2`] = ` +"import { TestDirective } from './test.directive'; + +describe('TestDirective', () => { + it('should create an instance', () => { + const directive = new TestDirective(); + expect(directive).toBeTruthy(); + }); +}); +" +`; + +exports[`directive generator should export the directive correctly when flat=false and path is nested deeper 3`] = ` +"import {NgModule} from \\"@angular/core\\"; +import { TestDirective } from './my-directives/test/test.directive'; + @NgModule({ + imports: [], + declarations: [, TestDirective], + exports: [, TestDirective] + }) + export class TestModule {}" +`; + +exports[`directive generator should generate a directive with test files and attach to the NgModule automatically 1`] = ` +"import { Directive } from '@angular/core'; + +@Directive({ + selector: '[projTest]' +}) +export class TestDirective { + + constructor() { } + +} +" +`; + +exports[`directive generator should generate a directive with test files and attach to the NgModule automatically 2`] = ` +"import { TestDirective } from './test.directive'; + +describe('TestDirective', () => { + it('should create an instance', () => { + const directive = new TestDirective(); + expect(directive).toBeTruthy(); + }); +}); +" +`; + +exports[`directive generator should generate a directive with test files and attach to the NgModule automatically 3`] = ` +"import {NgModule} from \\"@angular/core\\"; +import { TestDirective } from './test.directive'; + @NgModule({ + imports: [], + declarations: [, TestDirective], + exports: [] + }) + export class TestModule {}" +`; + +exports[`directive generator should import the directive correctly when flat=false 1`] = ` +"import { Directive } from '@angular/core'; + +@Directive({ + selector: '[projTest]' +}) +export class TestDirective { + + constructor() { } + +} +" +`; + +exports[`directive generator should import the directive correctly when flat=false 2`] = ` +"import { TestDirective } from './test.directive'; + +describe('TestDirective', () => { + it('should create an instance', () => { + const directive = new TestDirective(); + expect(directive).toBeTruthy(); + }); +}); +" +`; + +exports[`directive generator should import the directive correctly when flat=false 3`] = ` +"import {NgModule} from \\"@angular/core\\"; +import { TestDirective } from './test/test.directive'; + @NgModule({ + imports: [], + declarations: [, TestDirective], + exports: [] + }) + export class TestModule {}" +`; + +exports[`directive generator should import the directive correctly when flat=false and path is nested deeper 1`] = ` +"import { Directive } from '@angular/core'; + +@Directive({ + selector: '[projTest]' +}) +export class TestDirective { + + constructor() { } + +} +" +`; + +exports[`directive generator should import the directive correctly when flat=false and path is nested deeper 2`] = ` +"import { TestDirective } from './test.directive'; + +describe('TestDirective', () => { + it('should create an instance', () => { + const directive = new TestDirective(); + expect(directive).toBeTruthy(); + }); +}); +" +`; + +exports[`directive generator should import the directive correctly when flat=false and path is nested deeper 3`] = ` +"import {NgModule} from \\"@angular/core\\"; +import { TestDirective } from './my-directives/test/test.directive'; + @NgModule({ + imports: [], + declarations: [, TestDirective], + exports: [] + }) + export class TestModule {}" +`; + +exports[`directive generator should not generate test file when skipTests=true 1`] = ` +"import { Directive } from '@angular/core'; + +@Directive({ + selector: '[projTest]' +}) +export class TestDirective { + + constructor() { } + +} +" +`; + +exports[`directive generator should not generate test file when skipTests=true 2`] = ` +"import {NgModule} from \\"@angular/core\\"; +import { TestDirective } from './my-directives/test/test.directive'; + @NgModule({ + imports: [], + declarations: [, TestDirective], + exports: [] + }) + export class TestModule {}" +`; + +exports[`directive generator should not import the directive when skipImport=true 1`] = ` +"import { Directive } from '@angular/core'; + +@Directive({ + selector: '[projTest]' +}) +export class TestDirective { + + constructor() { } + +} +" +`; + +exports[`directive generator should not import the directive when skipImport=true 2`] = ` +"import { TestDirective } from './test.directive'; + +describe('TestDirective', () => { + it('should create an instance', () => { + const directive = new TestDirective(); + expect(directive).toBeTruthy(); + }); +}); +" +`; + +exports[`directive generator should not import the directive when skipImport=true 3`] = ` +"import {NgModule} from \\"@angular/core\\"; + @NgModule({ + imports: [], + declarations: [], + exports: [] + }) + export class TestModule {}" +`; + +exports[`directive generator should not import the directive when standalone=true 1`] = ` +"import { Directive } from '@angular/core'; + +@Directive({ + selector: '[projTest]', + standalone: true +}) +export class TestDirective { + + constructor() { } + +} +" +`; + +exports[`directive generator should not import the directive when standalone=true 2`] = ` +"import { TestDirective } from './test.directive'; + +describe('TestDirective', () => { + it('should create an instance', () => { + const directive = new TestDirective(); + expect(directive).toBeTruthy(); + }); +}); +" +`; + +exports[`directive generator should not import the directive when standalone=true 3`] = ` +"import {NgModule} from \\"@angular/core\\"; + @NgModule({ + imports: [], + declarations: [], + exports: [] + }) + export class TestModule {}" +`; diff --git a/packages/angular/src/generators/directive/directive.spec.ts b/packages/angular/src/generators/directive/directive.spec.ts new file mode 100644 index 0000000000..183eee863e --- /dev/null +++ b/packages/angular/src/generators/directive/directive.spec.ts @@ -0,0 +1,163 @@ +import { addProjectConfiguration, Tree } from '@nrwl/devkit'; +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { directiveGenerator } from './directive'; +import type { Schema } from './schema'; + +describe('directive generator', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + + addProjectConfiguration(tree, 'test', { + root: 'test', + sourceRoot: 'test/src', + }); + + tree.write( + 'test/src/test.module.ts', + `import {NgModule} from "@angular/core"; + @NgModule({ + imports: [], + declarations: [], + exports: [] + }) + export class TestModule {}` + ); + }); + + it('should generate a directive with test files and attach to the NgModule automatically', async () => { + // ARRANGE + + // ACT + await generateDirectiveWithDefaultOptions(tree); + + // ASSERT + expect(tree.read('test/src/test.directive.ts', 'utf-8')).toMatchSnapshot(); + expect( + tree.read('test/src/test.directive.spec.ts', 'utf-8') + ).toMatchSnapshot(); + expect(tree.read('test/src/test.module.ts', 'utf-8')).toMatchSnapshot(); + }); + + it('should import the directive correctly when flat=false', async () => { + // ARRANGE + + // ACT + await generateDirectiveWithDefaultOptions(tree, { flat: false }); + + // ASSERT + expect( + tree.read('test/src/test/test.directive.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('test/src/test/test.directive.spec.ts', 'utf-8') + ).toMatchSnapshot(); + expect(tree.read('test/src/test.module.ts', 'utf-8')).toMatchSnapshot(); + }); + + it('should not import the directive when standalone=true', async () => { + // ARRANGE + + // ACT + await generateDirectiveWithDefaultOptions(tree, { standalone: true }); + + // ASSERT + expect(tree.read('test/src/test.directive.ts', 'utf-8')).toMatchSnapshot(); + expect( + tree.read('test/src/test.directive.spec.ts', 'utf-8') + ).toMatchSnapshot(); + expect(tree.read('test/src/test.module.ts', 'utf-8')).toMatchSnapshot(); + }); + + it('should import the directive correctly when flat=false and path is nested deeper', async () => { + // ARRANGE + + // ACT + await generateDirectiveWithDefaultOptions(tree, { + flat: false, + path: 'test/src/my-directives', + }); + + // ASSERT + expect( + tree.read('test/src/my-directives/test/test.directive.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('test/src/my-directives/test/test.directive.spec.ts', 'utf-8') + ).toMatchSnapshot(); + expect(tree.read('test/src/test.module.ts', 'utf-8')).toMatchSnapshot(); + }); + + it('should export the directive correctly when flat=false and path is nested deeper', async () => { + // ARRANGE + + // ACT + await generateDirectiveWithDefaultOptions(tree, { + flat: false, + path: 'test/src/my-directives', + export: true, + }); + + // ASSERT + expect( + tree.read('test/src/my-directives/test/test.directive.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('test/src/my-directives/test/test.directive.spec.ts', 'utf-8') + ).toMatchSnapshot(); + expect(tree.read('test/src/test.module.ts', 'utf-8')).toMatchSnapshot(); + }); + + it('should not import the directive when skipImport=true', async () => { + // ARRANGE + + // ACT + await generateDirectiveWithDefaultOptions(tree, { + flat: false, + path: 'test/src/my-directives', + skipImport: true, + }); + + // ASSERT + expect( + tree.read('test/src/my-directives/test/test.directive.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.read('test/src/my-directives/test/test.directive.spec.ts', 'utf-8') + ).toMatchSnapshot(); + expect(tree.read('test/src/test.module.ts', 'utf-8')).toMatchSnapshot(); + }); + + it('should not generate test file when skipTests=true', async () => { + // ARRANGE + + // ACT + await generateDirectiveWithDefaultOptions(tree, { + flat: false, + path: 'test/src/my-directives', + skipTests: true, + }); + + // ASSERT + expect( + tree.read('test/src/my-directives/test/test.directive.ts', 'utf-8') + ).toMatchSnapshot(); + expect( + tree.exists('test/src/my-directives/test/test.directive.spec.ts') + ).toBeFalsy(); + expect(tree.read('test/src/test.module.ts', 'utf-8')).toMatchSnapshot(); + }); +}); + +async function generateDirectiveWithDefaultOptions( + tree: Tree, + overrides: Partial = {} +) { + await directiveGenerator(tree, { + name: 'test', + project: 'test', + flat: true, + ...overrides, + }); +} diff --git a/packages/angular/src/generators/directive/directive.ts b/packages/angular/src/generators/directive/directive.ts new file mode 100644 index 0000000000..a52f0cdcda --- /dev/null +++ b/packages/angular/src/generators/directive/directive.ts @@ -0,0 +1,160 @@ +import type { ProjectConfiguration, Tree } from '@nrwl/devkit'; +import { + formatFiles, + generateFiles, + getProjects, + joinPathFragments, + names, + readNxJson, + readProjectConfiguration, +} from '@nrwl/devkit'; +import type { Schema } from './schema'; +import { checkPathUnderProjectRoot } from '../utils/path'; +import { dirname } from 'path'; +import { insertNgModuleProperty } from '../utils'; +import { insertImport } from '@nrwl/js'; +import { ensureTypescript } from '@nrwl/js/src/utils/typescript/ensure-typescript'; + +let tsModule: typeof import('typescript'); + +export async function directiveGenerator(tree: Tree, schema: Schema) { + const projects = getProjects(tree); + if (!projects.has(schema.project)) { + throw new Error(`Project "${schema.project}" does not exist!`); + } + + checkPathUnderProjectRoot(tree, schema.project, schema.path); + + const project = readProjectConfiguration( + tree, + schema.project + ) as ProjectConfiguration & { prefix?: string }; + + const path = schema.path ?? `${project.sourceRoot}`; + const directiveNames = names(schema.name); + const selector = + schema.selector ?? + buildSelector(tree, schema.name, schema.prefix ?? project.prefix); + + const pathToGenerateFiles = schema.flat + ? './files/__directiveFileName__' + : './files'; + await generateFiles( + tree, + joinPathFragments(__dirname, pathToGenerateFiles), + path, + { + selector, + directiveClassName: directiveNames.className, + directiveFileName: directiveNames.fileName, + standalone: schema.standalone, + tpl: '', + } + ); + + if (schema.skipTests) { + const pathToSpecFile = joinPathFragments( + path, + `${!schema.flat ? `${directiveNames.fileName}/` : ``}${ + directiveNames.fileName + }.directive.spec.ts` + ); + + tree.delete(pathToSpecFile); + } + + if (!schema.skipImport && !schema.standalone) { + const modulePath = findModule(tree, path, schema.module); + addImportToNgModule(path, modulePath, schema, directiveNames, tree); + } + + if (!schema.skipFormat) { + await formatFiles(tree); + } +} + +function buildSelector(tree: Tree, name: string, prefix: string) { + let selector = names(name).fileName; + const selectorPrefix = names(prefix ?? readNxJson(tree).npmScope).fileName; + + return names(`${selectorPrefix}-${selector}`).propertyName; +} + +function findModule(tree: Tree, path: string, module?: string) { + let modulePath = ''; + let pathToSearch = path; + while (pathToSearch !== '/') { + if (module) { + const pathToModule = joinPathFragments(pathToSearch, module); + if (tree.exists(pathToModule)) { + modulePath = pathToModule; + break; + } + } else { + const potentialOptions = tree + .children(pathToSearch) + .filter((f) => f.endsWith('.module.ts')); + if (potentialOptions.length > 1) { + throw new Error( + `More than one NgModule was found. Please provide the NgModule you wish to use.` + ); + } else if (potentialOptions.length === 1) { + modulePath = joinPathFragments(pathToSearch, potentialOptions[0]); + break; + } + } + pathToSearch = dirname(pathToSearch); + } + + const moduleContents = tree.read(modulePath, 'utf-8'); + if (!moduleContents.includes('@NgModule')) { + throw new Error( + `Declaring module file (${modulePath}) does not contain an @NgModule Declaration.` + ); + } + + return modulePath; +} + +function addImportToNgModule( + path: string, + modulePath: string, + schema: Schema, + directiveNames: { + name: string; + className: string; + propertyName: string; + constantName: string; + fileName: string; + }, + tree: Tree +) { + if (!tsModule) { + tsModule = ensureTypescript(); + } + let relativePath = `${joinPathFragments( + path.replace(dirname(modulePath), ''), + !schema.flat ? directiveNames.fileName : '', + `${directiveNames.fileName}.directive` + )}`; + relativePath = relativePath.startsWith('/') + ? `.${relativePath}` + : `./${relativePath}`; + const directiveClassName = `${directiveNames.className}Directive`; + + const moduleContents = tree.read(modulePath, 'utf-8'); + const source = tsModule.createSourceFile( + modulePath, + moduleContents, + tsModule.ScriptTarget.Latest, + true + ); + + insertImport(tree, source, modulePath, directiveClassName, relativePath); + insertNgModuleProperty(tree, modulePath, directiveClassName, 'declarations'); + if (schema.export) { + insertNgModuleProperty(tree, modulePath, directiveClassName, 'exports'); + } +} + +export default directiveGenerator; diff --git a/packages/angular/src/generators/directive/files/__directiveFileName__/__directiveFileName__.directive.spec.ts__tpl__ b/packages/angular/src/generators/directive/files/__directiveFileName__/__directiveFileName__.directive.spec.ts__tpl__ new file mode 100644 index 0000000000..fdb318fd15 --- /dev/null +++ b/packages/angular/src/generators/directive/files/__directiveFileName__/__directiveFileName__.directive.spec.ts__tpl__ @@ -0,0 +1,8 @@ +import { <%= directiveClassName %>Directive } from './<%= directiveFileName %>.directive'; + +describe('<%= directiveClassName %>Directive', () => { + it('should create an instance', () => { + const directive = new <%= directiveClassName %>Directive(); + expect(directive).toBeTruthy(); + }); +}); diff --git a/packages/angular/src/generators/directive/files/__directiveFileName__/__directiveFileName__.directive.ts__tpl__ b/packages/angular/src/generators/directive/files/__directiveFileName__/__directiveFileName__.directive.ts__tpl__ new file mode 100644 index 0000000000..10881ae3de --- /dev/null +++ b/packages/angular/src/generators/directive/files/__directiveFileName__/__directiveFileName__.directive.ts__tpl__ @@ -0,0 +1,11 @@ +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[<%= selector %>]'<% if(standalone) {%>, + standalone: true<%}%> +}) +export class <%= directiveClassName %>Directive { + + constructor() { } + +} diff --git a/packages/angular/src/generators/directive/schema.d.ts b/packages/angular/src/generators/directive/schema.d.ts new file mode 100644 index 0000000000..554dbcfdf6 --- /dev/null +++ b/packages/angular/src/generators/directive/schema.d.ts @@ -0,0 +1,14 @@ +export interface Schema { + name: string; + project: string; + path?: string; + prefix?: string; + skipTests?: boolean; + skipImport?: boolean; + selector?: string; + standalone?: boolean; + flat?: boolean; + module?: string; + export?: boolean; + skipFormat?: boolean; +} diff --git a/packages/angular/src/generators/directive/schema.json b/packages/angular/src/generators/directive/schema.json new file mode 100644 index 0000000000..f0073f8dfb --- /dev/null +++ b/packages/angular/src/generators/directive/schema.json @@ -0,0 +1,89 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "GeneratorAngularDirective", + "cli": "nx", + "title": "Nx Angular Directive Options Schema", + "type": "object", + "description": "Creates a new, generic directive definition in the given project.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of the new directive.", + "$default": { + "$source": "argv", + "index": 0 + }, + "x-prompt": "What name would you like to use for the directive?" + }, + "path": { + "type": "string", + "format": "path", + "$default": { + "$source": "workingDirectory" + }, + "description": "The path at which to create the interface that defines the directive, relative to the workspace root.", + "visible": false + }, + "project": { + "type": "string", + "description": "The name of the project.", + "x-dropdown": "projects" + }, + "prefix": { + "type": "string", + "description": "A prefix to apply to generated selectors.", + "alias": "p", + "oneOf": [ + { + "maxLength": 0 + }, + { + "minLength": 1, + "format": "html-selector" + } + ] + }, + "skipTests": { + "type": "boolean", + "description": "Do not create \"spec.ts\" test files for the new class.", + "default": false + }, + "skipImport": { + "type": "boolean", + "description": "Do not import this directive into the owning NgModule.", + "default": false + }, + "selector": { + "type": "string", + "format": "html-selector", + "description": "The HTML selector to use for this directive." + }, + "standalone": { + "description": "Whether the generated directive is standalone.", + "type": "boolean", + "default": false + }, + "flat": { + "type": "boolean", + "description": "When true (the default), creates the new files at the top level of the current project.", + "default": true + }, + "module": { + "type": "string", + "description": "The filename of the declaring NgModule.", + "alias": "m" + }, + "export": { + "type": "boolean", + "default": false, + "description": "The declaring NgModule exports this directive." + }, + "skipFormat": { + "type": "boolean", + "default": false, + "description": "Skip formatting of files." + } + }, + "required": ["name", "project"] +} diff --git a/packages/angular/src/generators/utils/insert-ngmodule-import.ts b/packages/angular/src/generators/utils/insert-ngmodule-import.ts index 04d5441996..4e1cbeba94 100644 --- a/packages/angular/src/generators/utils/insert-ngmodule-import.ts +++ b/packages/angular/src/generators/utils/insert-ngmodule-import.ts @@ -1,25 +1,17 @@ import { applyChangesToString, ChangeType, Tree } from '@nrwl/devkit'; -import { +import type { __String, CallExpression, ClassDeclaration, - createSourceFile, Decorator, - getDecorators, ImportDeclaration, - isArrayLiteralExpression, - isCallExpression, - isClassDeclaration, - isIdentifier, - isImportDeclaration, - isNamedImports, - isObjectLiteralExpression, - isPropertyAssignment, ObjectLiteralExpression, PropertyAssignment, - ScriptTarget, SourceFile, } from 'typescript'; +import { ensureTypescript } from '@nrwl/js/src/utils/typescript/ensure-typescript'; + +let tsModule: typeof import('typescript'); type ngModuleDecoratorProperty = | 'imports' @@ -33,12 +25,15 @@ export function insertNgModuleProperty( name: string, property: ngModuleDecoratorProperty ) { + if (!tsModule) { + tsModule = ensureTypescript(); + } const contents = tree.read(modulePath).toString('utf-8'); - const sourceFile = createSourceFile( + const sourceFile = tsModule.createSourceFile( modulePath, contents, - ScriptTarget.ESNext + tsModule.ScriptTarget.ESNext ); const coreImport = findImport(sourceFile, '@angular/core'); @@ -63,12 +58,14 @@ export function insertNgModuleProperty( let ngModuleDecorator: Decorator; try { ngModuleClassDeclaration = findDecoratedClass(sourceFile, ngModuleName); - ngModuleDecorator = getDecorators(ngModuleClassDeclaration).find( - (decorator) => - isCallExpression(decorator.expression) && - isIdentifier(decorator.expression.expression) && - decorator.expression.expression.escapedText === ngModuleName - ); + ngModuleDecorator = tsModule + .getDecorators(ngModuleClassDeclaration) + .find( + (decorator) => + tsModule.isCallExpression(decorator.expression) && + tsModule.isIdentifier(decorator.expression.expression) && + decorator.expression.expression.escapedText === ngModuleName + ); } catch { // Support for TS < 4.8 ngModuleClassDeclaration = findDecoratedClassLegacy( @@ -78,8 +75,8 @@ export function insertNgModuleProperty( // @ts-ignore ngModuleDecorator = ngModuleClassDeclaration.decorators.find( (decorator) => - isCallExpression(decorator.expression) && - isIdentifier(decorator.expression.expression) && + tsModule.isCallExpression(decorator.expression) && + tsModule.isIdentifier(decorator.expression.expression) && decorator.expression.expression.escapedText === ngModuleName ); } @@ -96,7 +93,7 @@ export function insertNgModuleProperty( ]); tree.write(modulePath, newContents); } else { - if (!isObjectLiteralExpression(ngModuleCall.arguments[0])) { + if (!tsModule.isObjectLiteralExpression(ngModuleCall.arguments[0])) { throw new Error( `The NgModule options for ${ngModuleClassDeclaration.name.escapedText} in ${modulePath} is not an object literal` ); @@ -123,7 +120,7 @@ export function insertNgModuleProperty( ]); tree.write(modulePath, newContents); } else { - if (!isArrayLiteralExpression(typeProperty.initializer)) { + if (!tsModule.isArrayLiteralExpression(typeProperty.initializer)) { throw new Error( `The NgModule ${property} for ${ngModuleClassDeclaration.name.escapedText} in ${modulePath} is not an array literal` ); @@ -156,7 +153,13 @@ export function insertNgModuleImport( } function findImport(sourceFile: SourceFile, importPath: string) { - const importStatements = sourceFile.statements.filter(isImportDeclaration); + if (!tsModule) { + tsModule = ensureTypescript(); + } + + const importStatements = sourceFile.statements.filter( + tsModule.isImportDeclaration + ); return importStatements.find( (statement) => @@ -168,7 +171,11 @@ function findImport(sourceFile: SourceFile, importPath: string) { } function getNamedImport(coreImport: ImportDeclaration, importName: string) { - if (!isNamedImports(coreImport.importClause.namedBindings)) { + if (!tsModule) { + tsModule = ensureTypescript(); + } + + if (!tsModule.isNamedImports(coreImport.importClause.namedBindings)) { throw new Error( `The import from ${coreImport.moduleSpecifier} does not have named imports.` ); @@ -176,9 +183,9 @@ function getNamedImport(coreImport: ImportDeclaration, importName: string) { return coreImport.importClause.namedBindings.elements.find((namedImport) => namedImport.propertyName - ? isIdentifier(namedImport.propertyName) && + ? tsModule.isIdentifier(namedImport.propertyName) && namedImport.propertyName.escapedText === importName - : isIdentifier(namedImport.name) && + : tsModule.isIdentifier(namedImport.name) && namedImport.name.escapedText === importName ); } @@ -187,14 +194,20 @@ function findDecoratedClass( sourceFile: SourceFile, ngModuleName: __String ): ClassDeclaration | undefined { - const classDeclarations = sourceFile.statements.filter(isClassDeclaration); + if (!tsModule) { + tsModule = ensureTypescript(); + } + + const classDeclarations = sourceFile.statements.filter( + tsModule.isClassDeclaration + ); return classDeclarations.find((declaration) => { - const decorators = getDecorators(declaration); + const decorators = tsModule.getDecorators(declaration); if (decorators) { return decorators.some( (decorator) => - isCallExpression(decorator.expression) && - isIdentifier(decorator.expression.expression) && + tsModule.isCallExpression(decorator.expression) && + tsModule.isIdentifier(decorator.expression.expression) && decorator.expression.expression.escapedText === ngModuleName ); } @@ -206,14 +219,20 @@ function findDecoratedClassLegacy( sourceFile: SourceFile, ngModuleName: __String ) { - const classDeclarations = sourceFile.statements.filter(isClassDeclaration); + if (!tsModule) { + tsModule = ensureTypescript(); + } + + const classDeclarations = sourceFile.statements.filter( + tsModule.isClassDeclaration + ); return classDeclarations.find( (declaration) => declaration.decorators && (declaration.decorators as any[]).some( (decorator) => - isCallExpression(decorator.expression) && - isIdentifier(decorator.expression.expression) && + tsModule.isCallExpression(decorator.expression) && + tsModule.isIdentifier(decorator.expression.expression) && decorator.expression.expression.escapedText === ngModuleName ) ); @@ -223,10 +242,14 @@ function findPropertyAssignment( ngModuleOptions: ObjectLiteralExpression, propertyName: ngModuleDecoratorProperty ) { + if (!tsModule) { + tsModule = ensureTypescript(); + } + return ngModuleOptions.properties.find( (property) => - isPropertyAssignment(property) && - isIdentifier(property.name) && + tsModule.isPropertyAssignment(property) && + tsModule.isIdentifier(property.name) && property.name.escapedText === propertyName ) as PropertyAssignment; } diff --git a/packages/angular/src/generators/utils/path.ts b/packages/angular/src/generators/utils/path.ts index 85559e8c83..ae92e648b9 100644 --- a/packages/angular/src/generators/utils/path.ts +++ b/packages/angular/src/generators/utils/path.ts @@ -1,4 +1,11 @@ -import { joinPathFragments, workspaceRoot } from '@nrwl/devkit'; +import { + joinPathFragments, + normalizePath, + readNxJson, + readProjectConfiguration, + Tree, + workspaceRoot, +} from '@nrwl/devkit'; import { basename, dirname, relative } from 'path'; export function pathStartsWith(path1: string, path2: string): boolean { @@ -22,3 +29,28 @@ export function getRelativeImportToFile( basename(targetFilePath, '.ts') )}`; } + +export function checkPathUnderProjectRoot( + tree: Tree, + projectName: string, + path: string +): void { + if (!path) { + return; + } + + const project = projectName ?? readNxJson(tree).defaultProject; + const { root } = readProjectConfiguration(tree, project); + + let pathToComponent = normalizePath(path); + pathToComponent = pathToComponent.startsWith('/') + ? pathToComponent.slice(1) + : pathToComponent; + + if (!pathStartsWith(pathToComponent, root)) { + throw new Error( + `The path provided (${path}) does not exist under the project root (${root}). ` + + `Please make sure to provide a path that exists under the project root.` + ); + } +}