diff --git a/docs/generated/packages/angular/generators/component.json b/docs/generated/packages/angular/generators/component.json index 1d786ff1a5a8b..8af01710c8708 100644 --- a/docs/generated/packages/angular/generators/component.json +++ b/docs/generated/packages/angular/generators/component.json @@ -1,32 +1,38 @@ { "name": "component", - "factory": "./src/generators/component/component", + "factory": "./src/generators/component/component#componentGeneratorInternal", "schema": { "$schema": "http://json-schema.org/draft-07/schema", "$id": "SchematicsAngularComponent", "title": "Angular Component Schema", "cli": "nx", "type": "object", - "description": "Creates a new, generic Angular component definition in the given or default project.", + "description": "Creates a new Angular component.", "additionalProperties": false, "properties": { - "path": { + "name": { + "type": "string", + "description": "The name of the component.", + "$default": { "$source": "argv", "index": 0 }, + "x-prompt": "What name would you like to use for the component?" + }, + "directory": { + "type": "string", + "description": "The directory at which to create the component file. When `--nameAndDirectoryFormat=as-provided`, it will be relative to the current working directory. Otherwise, it will be relative to the workspace root.", + "aliases": ["dir", "path"], + "x-priority": "important" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the component in the directory as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", "type": "string", - "format": "path", - "description": "The path at which to create the component file, relative to the current workspace. Default is a folder with the same name as the component in the project root.", - "visible": false + "enum": ["as-provided", "derived"] }, "project": { "type": "string", "description": "The name of the project.", "$default": { "$source": "projectName" }, - "x-dropdown": "projects" - }, - "name": { - "type": "string", - "description": "The name of the component.", - "$default": { "$source": "argv", "index": 0 }, - "x-prompt": "What name would you like to use for the component?" + "x-dropdown": "projects", + "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. The project will be determined from the directory provided. It will be removed in Nx v18." }, "prefix": { "type": "string", @@ -89,7 +95,8 @@ "flat": { "type": "boolean", "description": "Create the new files at the top level of the current project.", - "default": false + "default": false, + "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. It will be removed in Nx v18." }, "skipImport": { "type": "boolean", @@ -124,13 +131,13 @@ "x-priority": "internal" } }, - "required": ["name", "project"], + "required": ["name"], "examplesFile": "## Examples\n\n{% tabs %}\n{% tab label=\"Simple Component\" %}\n\nCreate a component named `my-component`:\n\n```bash\nnx g @nx/angular:component my-component\n```\n\n{% /tab %}\n\n{% tab label=\"Standalone Component\" %}\n\nCreate a standalone component named `my-component`:\n\n```bash\nnx g @nx/angular:component my-component --standalone\n```\n\n{% /tab %}\n\n{% tab label=\"Single File Component\" %}\n\nCreate a component named `my-component` with inline styles and inline template:\n\n```bash\nnx g @nx/angular:component my-component --inlineStyle --inlineTemplate\n```\n\n{% /tab %}\n\n{% tab label=\"Component with OnPush Change Detection Strategy\" %}\n\nCreate a component named `my-component` with OnPush Change Detection Strategy:\n\n```bash\nnx g @nx/angular:component my-component --changeDetection=OnPush\n```\n\n{% /tab %}\n", "presets": [] }, "aliases": ["c"], "description": "Generate an Angular Component.", - "implementation": "/packages/angular/src/generators/component/component.ts", + "implementation": "/packages/angular/src/generators/component/component#componentGeneratorInternal.ts", "hidden": false, "path": "/packages/angular/src/generators/component/schema.json", "type": "generator" diff --git a/packages/angular/generators.json b/packages/angular/generators.json index 1f4ffbe4ea583..5d632cdaec398 100644 --- a/packages/angular/generators.json +++ b/packages/angular/generators.json @@ -165,7 +165,7 @@ "description": "Creates an Angular application." }, "component": { - "factory": "./src/generators/component/component", + "factory": "./src/generators/component/component#componentGeneratorInternal", "schema": "./src/generators/component/schema.json", "aliases": ["c"], "description": "Generate an Angular Component." diff --git a/packages/angular/src/generators/component/component.ts b/packages/angular/src/generators/component/component.ts index 3b76b3cc0c5a4..5f9c3a383561c 100644 --- a/packages/angular/src/generators/component/component.ts +++ b/packages/angular/src/generators/component/component.ts @@ -15,21 +15,27 @@ import { import type { Schema } from './schema'; export async function componentGenerator(tree: Tree, rawOptions: Schema) { - validateOptions(tree, rawOptions); - const options = normalizeOptions(tree, rawOptions); + await componentGeneratorInternal(tree, { + nameAndDirectoryFormat: 'derived', + ...rawOptions, + }); +} - const componentNames = names(options.name); - const typeNames = names(options.type); +export async function componentGeneratorInternal( + tree: Tree, + rawOptions: Schema +) { + validateOptions(tree, rawOptions); + const options = await normalizeOptions(tree, rawOptions); generateFiles( tree, joinPathFragments(__dirname, 'files'), options.directory, { - fileName: componentNames.fileName, - className: componentNames.className, - type: typeNames.fileName, - typeClassName: typeNames.className, + name: options.name, + fileName: options.fileName, + symbolName: options.symbolName, style: options.style, inlineStyle: options.inlineStyle, inlineTemplate: options.inlineTemplate, @@ -46,7 +52,7 @@ export async function componentGenerator(tree: Tree, rawOptions: Schema) { if (options.skipTests) { const pathToSpecFile = joinPathFragments( options.directory, - `${componentNames.fileName}.${typeNames.fileName}.spec.ts` + `${options.fileName}.spec.ts` ); tree.delete(pathToSpecFile); @@ -55,7 +61,7 @@ export async function componentGenerator(tree: Tree, rawOptions: Schema) { if (options.inlineTemplate) { const pathToTemplateFile = joinPathFragments( options.directory, - `${componentNames.fileName}.${typeNames.fileName}.html` + `${options.fileName}.html` ); tree.delete(pathToTemplateFile); @@ -64,7 +70,7 @@ export async function componentGenerator(tree: Tree, rawOptions: Schema) { if (options.style === 'none' || options.inlineStyle) { const pathToStyleFile = joinPathFragments( options.directory, - `${componentNames.fileName}.${typeNames.fileName}.${options.style}` + `${options.fileName}.${options.style}` ); tree.delete(pathToStyleFile); @@ -78,13 +84,13 @@ export async function componentGenerator(tree: Tree, rawOptions: Schema) { ); addToNgModule( tree, - options.path, + options.directory, modulePath, - componentNames.fileName, - `${componentNames.className}${typeNames.className}`, - `${componentNames.fileName}.${typeNames.fileName}`, + options.name, + options.symbolName, + options.fileName, 'declarations', - options.flat, + true, options.export ); } diff --git a/packages/angular/src/generators/component/files/__fileName__.__type__.__style__ b/packages/angular/src/generators/component/files/__fileName__.__style__ similarity index 100% rename from packages/angular/src/generators/component/files/__fileName__.__type__.__style__ rename to packages/angular/src/generators/component/files/__fileName__.__style__ diff --git a/packages/angular/src/generators/component/files/__fileName__.__type__.html__tpl__ b/packages/angular/src/generators/component/files/__fileName__.__type__.html__tpl__ deleted file mode 100644 index 2fd10df414b4e..0000000000000 --- a/packages/angular/src/generators/component/files/__fileName__.__type__.html__tpl__ +++ /dev/null @@ -1 +0,0 @@ -

<%= fileName %> works!

diff --git a/packages/angular/src/generators/component/files/__fileName__.__type__.spec.ts__tpl__ b/packages/angular/src/generators/component/files/__fileName__.__type__.spec.ts__tpl__ deleted file mode 100644 index 3a6f444f216e9..0000000000000 --- a/packages/angular/src/generators/component/files/__fileName__.__type__.spec.ts__tpl__ +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { <%= className %><%= typeClassName %> } from './<%= fileName %><%= type ? '.' + type: '' %>'; - -describe('<%= className %><%= typeClassName %>', () => { - let component: <%= className %><%= typeClassName %>; - let fixture: ComponentFixture<<%= className %><%= typeClassName %>>; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - <%= standalone ? 'imports' : 'declarations' %>: [ <%= className %><%= typeClassName %> ] - }) - .compileComponents(); - - fixture = TestBed.createComponent(<%= className %><%= typeClassName %>); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/packages/angular/src/generators/component/files/__fileName__.html__tpl__ b/packages/angular/src/generators/component/files/__fileName__.html__tpl__ new file mode 100644 index 0000000000000..0b2a8c4bdcfac --- /dev/null +++ b/packages/angular/src/generators/component/files/__fileName__.html__tpl__ @@ -0,0 +1 @@ +

<%= name %> works!

diff --git a/packages/angular/src/generators/component/files/__fileName__.spec.ts__tpl__ b/packages/angular/src/generators/component/files/__fileName__.spec.ts__tpl__ new file mode 100644 index 0000000000000..b95c25c90e598 --- /dev/null +++ b/packages/angular/src/generators/component/files/__fileName__.spec.ts__tpl__ @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { <%= symbolName %> } from './<%= fileName %>'; + +describe('<%= symbolName %>', () => { + let component: <%= symbolName %>; + let fixture: ComponentFixture<<%= symbolName %>>; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + <%= standalone ? 'imports' : 'declarations' %>: [ <%= symbolName %> ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(<%= symbolName %>); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/packages/angular/src/generators/component/files/__fileName__.__type__.ts__tpl__ b/packages/angular/src/generators/component/files/__fileName__.ts__tpl__ similarity index 69% rename from packages/angular/src/generators/component/files/__fileName__.__type__.ts__tpl__ rename to packages/angular/src/generators/component/files/__fileName__.ts__tpl__ index 6cb79a450b3d3..a5e19026fff95 100644 --- a/packages/angular/src/generators/component/files/__fileName__.__type__.ts__tpl__ +++ b/packages/angular/src/generators/component/files/__fileName__.ts__tpl__ @@ -5,8 +5,8 @@ import { CommonModule } from '@angular/common';<% } %> selector: '<%= selector %>',<%}%><% if(standalone) {%> standalone: true, imports: [CommonModule],<%}%><% if(inlineTemplate) { %> - template: `

<%= fileName %> works!

`<% } else { %> - templateUrl: './<%= fileName %><%= type ? '.' + type : '' %>.html'<% } if(inlineStyle) { %>, + template: `

<%= name %> works!

`<% } else { %> + templateUrl: './<%= fileName %>.html'<% } if(inlineStyle) { %>, styles: [<% if(displayBlock){ %> ` :host { @@ -14,8 +14,8 @@ import { CommonModule } from '@angular/common';<% } %> } `<% } %> ]<% } else if (style !== 'none') { %>, - styleUrls: ['./<%= fileName %><%= type ? '.' + type : '' %>.<%= style %>']<% } %><% if(!!viewEncapsulation) { %>, + styleUrls: ['./<%= fileName %>.<%= style %>']<% } %><% if(!!viewEncapsulation) { %>, encapsulation: ViewEncapsulation.<%= viewEncapsulation %><% } if (changeDetection !== 'Default') { %>, changeDetection: ChangeDetectionStrategy.<%= changeDetection %><% } %> }) -export class <%= className %><%= typeClassName %> {} +export class <%= symbolName %> {} diff --git a/packages/angular/src/generators/component/lib/module.ts b/packages/angular/src/generators/component/lib/module.ts index e6648666f531b..57120371f9166 100644 --- a/packages/angular/src/generators/component/lib/module.ts +++ b/packages/angular/src/generators/component/lib/module.ts @@ -16,11 +16,11 @@ export function findModuleFromOptions( if (!options.module) { return normalizePath(findModule(tree, options.directory, projectRoot)); } else { - const modulePath = joinPathFragments(options.path, options.module); + const modulePath = joinPathFragments(options.directory, options.module); const componentPath = options.directory; const moduleBaseName = basename(modulePath); - const candidateSet = new Set([options.path]); + const candidateSet = new Set([options.directory]); const projectRootParent = dirname(projectRoot); for (let dir = modulePath; dir !== projectRootParent; dir = dirname(dir)) { diff --git a/packages/angular/src/generators/component/lib/normalize-options.ts b/packages/angular/src/generators/component/lib/normalize-options.ts index 5ca348798e115..10581ff11bf18 100644 --- a/packages/angular/src/generators/component/lib/normalize-options.ts +++ b/packages/angular/src/generators/component/lib/normalize-options.ts @@ -1,21 +1,39 @@ import type { Tree } from '@nx/devkit'; -import { readProjectConfiguration } from '@nx/devkit'; +import { names, readProjectConfiguration } from '@nx/devkit'; +import { determineArtifactNameAndDirectoryOptions } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; import type { AngularProjectConfiguration } from '../../../utils/types'; -import { normalizeNameAndPaths } from '../../utils/path'; import { buildSelector } from '../../utils/selector'; import type { NormalizedSchema, Schema } from '../schema'; -export function normalizeOptions( +export async function normalizeOptions( tree: Tree, options: Schema -): NormalizedSchema { +): Promise { options.type ??= 'component'; - const { directory, filePath, name, path, root, sourceRoot } = - normalizeNameAndPaths(tree, options); + const { + artifactName: name, + directory, + fileName, + filePath, + project, + } = await determineArtifactNameAndDirectoryOptions(tree, { + artifactType: 'component', + callingGenerator: '@nx/angular:component', + name: options.name, + directory: options.directory ?? options.path, + flat: options.flat, + nameAndDirectoryFormat: options.nameAndDirectoryFormat, + project: options.project, + suffix: options.type ?? 'component', + }); + + const { className } = names(name); + const { className: suffixClassName } = names(options.type); + const symbolName = `${className}${suffixClassName}`; - const { prefix } = readProjectConfiguration( + const { prefix, root, sourceRoot } = readProjectConfiguration( tree, - options.project + project ) as AngularProjectConfiguration; const selector = @@ -25,12 +43,13 @@ export function normalizeOptions( return { ...options, name, + project, changeDetection: options.changeDetection ?? 'Default', style: options.style ?? 'css', - flat: options.flat ?? false, directory, + fileName, filePath, - path, + symbolName, projectSourceRoot: sourceRoot, projectRoot: root, selector, diff --git a/packages/angular/src/generators/component/lib/validate-options.ts b/packages/angular/src/generators/component/lib/validate-options.ts index 01ce0032013ff..d51a443cd84fb 100644 --- a/packages/angular/src/generators/component/lib/validate-options.ts +++ b/packages/angular/src/generators/component/lib/validate-options.ts @@ -1,13 +1,7 @@ import type { Tree } from '@nx/devkit'; -import { - validatePathIsUnderProjectRoot, - validateProject, - validateStandaloneOption, -} from '../../utils/validations'; +import { validateStandaloneOption } from '../../utils/validations'; import type { Schema } from '../schema'; export function validateOptions(tree: Tree, options: Schema): void { - validateProject(tree, options.project); - validatePathIsUnderProjectRoot(tree, options.project, options.path); validateStandaloneOption(tree, options.standalone); } diff --git a/packages/angular/src/generators/component/schema.d.ts b/packages/angular/src/generators/component/schema.d.ts index 510d1c099bf70..8564b49f16e76 100644 --- a/packages/angular/src/generators/component/schema.d.ts +++ b/packages/angular/src/generators/component/schema.d.ts @@ -1,7 +1,9 @@ +import type { NameAndDirectoryFormat } from '@nx/devkit/src/generators/artifact-name-and-directory-utils'; + export interface Schema { name: string; - project: string; - path?: string; + directory?: string; + nameAndDirectoryFormat?: NameAndDirectoryFormat; displayBlock?: boolean; inlineStyle?: boolean; inlineTemplate?: boolean; @@ -11,7 +13,6 @@ export interface Schema { style?: 'css' | 'scss' | 'sass' | 'less' | 'none'; skipTests?: boolean; type?: string; - flat?: boolean; skipImport?: boolean; selector?: string; module?: string; @@ -19,12 +20,29 @@ export interface Schema { export?: boolean; prefix?: string; skipFormat?: boolean; + + /** + * @deprecated Provide the `directory` option instead and use the `as-provided` format. It will be removed in Nx v18. + */ + flat?: boolean; + /** + * @deprecated Provide the `directory` option instead. It will be removed in Nx v18. + */ + path?: string; + /** + * @deprecated Provide the `directory` option instead. The project will be determined from the directory provided. It will be removed in Nx v18. + */ + project?: string; } export interface NormalizedSchema extends Schema { directory: string; filePath: string; + project: string; projectSourceRoot: string; projectRoot: string; selector: string; + + fileName: string; + symbolName: string; } diff --git a/packages/angular/src/generators/component/schema.json b/packages/angular/src/generators/component/schema.json index 03823ac4d84e9..d66db2fcdc5d4 100644 --- a/packages/angular/src/generators/component/schema.json +++ b/packages/angular/src/generators/component/schema.json @@ -4,23 +4,9 @@ "title": "Angular Component Schema", "cli": "nx", "type": "object", - "description": "Creates a new, generic Angular component definition in the given or default project.", + "description": "Creates a new Angular component.", "additionalProperties": false, "properties": { - "path": { - "type": "string", - "format": "path", - "description": "The path at which to create the component file, relative to the current workspace. Default is a folder with the same name as the component in the project root.", - "visible": false - }, - "project": { - "type": "string", - "description": "The name of the project.", - "$default": { - "$source": "projectName" - }, - "x-dropdown": "projects" - }, "name": { "type": "string", "description": "The name of the component.", @@ -30,6 +16,26 @@ }, "x-prompt": "What name would you like to use for the component?" }, + "directory": { + "type": "string", + "description": "The directory at which to create the component file. When `--nameAndDirectoryFormat=as-provided`, it will be relative to the current working directory. Otherwise, it will be relative to the workspace root.", + "aliases": ["dir", "path"], + "x-priority": "important" + }, + "nameAndDirectoryFormat": { + "description": "Whether to generate the component in the directory as provided, relative to the current working directory and ignoring the project (`as-provided`) or generate it using the project and directory relative to the workspace root (`derived`).", + "type": "string", + "enum": ["as-provided", "derived"] + }, + "project": { + "type": "string", + "description": "The name of the project.", + "$default": { + "$source": "projectName" + }, + "x-dropdown": "projects", + "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. The project will be determined from the directory provided. It will be removed in Nx v18." + }, "prefix": { "type": "string", "description": "The prefix to apply to the generated component selector.", @@ -91,7 +97,8 @@ "flat": { "type": "boolean", "description": "Create the new files at the top level of the current project.", - "default": false + "default": false, + "x-deprecated": "Provide the `directory` option instead and use the `as-provided` format. It will be removed in Nx v18." }, "skipImport": { "type": "boolean", @@ -126,6 +133,6 @@ "x-priority": "internal" } }, - "required": ["name", "project"], + "required": ["name"], "examplesFile": "../../../docs/component-examples.md" } diff --git a/packages/angular/src/generators/utils/find-module.ts b/packages/angular/src/generators/utils/find-module.ts index 0d7af2b46b3f2..aae9d9c69a933 100644 --- a/packages/angular/src/generators/utils/find-module.ts +++ b/packages/angular/src/generators/utils/find-module.ts @@ -58,6 +58,8 @@ export function addToNgModule( className: string, fileName: string, ngModuleProperty: ngModuleDecoratorProperty, + // TODO(leo): remove once all consumers are updated + // // check if exported in the public api isFlat = true, isExported = false ) { diff --git a/packages/devkit/src/generators/artifact-name-and-directory-utils.spec.ts b/packages/devkit/src/generators/artifact-name-and-directory-utils.spec.ts new file mode 100644 index 0000000000000..2b4656c63a769 --- /dev/null +++ b/packages/devkit/src/generators/artifact-name-and-directory-utils.spec.ts @@ -0,0 +1,797 @@ +import * as enquirer from 'enquirer'; +import { addProjectConfiguration } from 'nx/src/devkit-exports'; +import { createTreeWithEmptyWorkspace } from 'nx/src/generators/testing-utils/create-tree-with-empty-workspace'; +import type { Tree } from 'nx/src/generators/tree'; +import { workspaceRoot } from 'nx/src/utils/workspace-root'; +import { join } from 'path'; +import { determineArtifactNameAndDirectoryOptions } from './artifact-name-and-directory-utils'; + +describe('determineArtifactNameAndDirectoryOptions', () => { + let tree: Tree; + let originalInteractiveValue; + let originalCIValue; + let originalIsTTYValue; + let originalInitCwd; + + function ensureInteractiveMode() { + process.env.NX_INTERACTIVE = 'true'; + process.env.CI = 'false'; + process.stdout.isTTY = true; + } + + function restoreOriginalInteractiveMode() { + process.env.NX_INTERACTIVE = originalInteractiveValue; + process.env.CI = originalCIValue; + process.stdout.isTTY = originalIsTTYValue; + } + + function setCwd(path: string) { + process.env.INIT_CWD = join(workspaceRoot, path); + } + + function restoreCwd() { + if (originalInitCwd === undefined) { + delete process.env.INIT_CWD; + } else { + process.env.INIT_CWD = originalInitCwd; + } + } + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + jest.clearAllMocks(); + + originalInteractiveValue = process.env.NX_INTERACTIVE; + originalCIValue = process.env.CI; + originalIsTTYValue = process.stdout.isTTY; + originalInitCwd = process.env.INIT_CWD; + }); + + it('should throw an error when the resolver directory is not under any project root', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + setCwd('some/path'); + + await expect( + determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The current working directory "some/path" does not exist under any project root. Please make sure to navigate to a location or provide a directory that exists under a project root."` + ); + + restoreCwd(); + }); + + it('should throw an error when the provided project does not exist', async () => { + await expect( + determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app1', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The provided project "app1" does not exist! Please provide an existing project name."` + ); + }); + + it('should throw when receiving a path as the name and a directory', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + await expect( + determineArtifactNameAndDirectoryOptions(tree, { + name: 'apps/app1/foo/bar/myComponent', + directory: 'foo/bar', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"You can't specify both a directory (foo/bar) and a name with a directory path (apps/app1/foo/bar/myComponent). Please specify either a directory or a name with a directory path."` + ); + }); + + describe('as-provided', () => { + it('should return options as provided when there is a project at the cwd', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + setCwd('apps/app1'); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + nameAndDirectoryFormat: 'as-provided', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1', + fileName: 'myComponent', + filePath: 'apps/app1/myComponent.ts', + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + + restoreCwd(); + }); + + it('should return the options as provided when directory is provided', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + directory: 'apps/app1', + nameAndDirectoryFormat: 'as-provided', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1', + fileName: 'myComponent', + filePath: 'apps/app1/myComponent.ts', + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it(`should handle window's style paths correctly`, async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + directory: 'apps\\app1', + nameAndDirectoryFormat: 'as-provided', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1', + fileName: 'myComponent', + filePath: 'apps/app1/myComponent.ts', + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should ignore the project and use the provided directory', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + addProjectConfiguration(tree, 'app2', { + root: 'apps/app2', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app2', + directory: 'apps/app1/foo/bar', + nameAndDirectoryFormat: 'as-provided', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/foo/bar', + fileName: 'myComponent', + filePath: 'apps/app1/foo/bar/myComponent.ts', + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should support receiving a path as the name', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'apps/app1/foo/bar/myComponent', + nameAndDirectoryFormat: 'as-provided', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/foo/bar', + fileName: 'myComponent', + filePath: 'apps/app1/foo/bar/myComponent.ts', + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should support receiving a suffix', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + suffix: 'component', + directory: 'apps/app1', + nameAndDirectoryFormat: 'as-provided', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1', + fileName: 'myComponent.component', + filePath: 'apps/app1/myComponent.component.ts', + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should support receiving a fileName', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + fileName: 'myComponent.component', + directory: 'apps/app1', + nameAndDirectoryFormat: 'as-provided', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1', + fileName: 'myComponent.component', + filePath: 'apps/app1/myComponent.component.ts', + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should ignore "--pascalCaseFile"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + pascalCaseFile: true, + directory: 'apps/app1', + nameAndDirectoryFormat: 'as-provided', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1', + fileName: 'myComponent', + filePath: 'apps/app1/myComponent.ts', + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should ignore "--pascalCaseDirectory"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + pascalCaseDirectory: true, + directory: 'apps/app1', + nameAndDirectoryFormat: 'as-provided', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1', + fileName: 'myComponent', + filePath: 'apps/app1/myComponent.ts', + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + + it('should support receiving a different file extension', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + fileExtension: 'tsx', + directory: 'apps/app1', + nameAndDirectoryFormat: 'as-provided', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1', + fileName: 'myComponent', + filePath: 'apps/app1/myComponent.tsx', + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + }); + }); + + describe('derived', () => { + it('should infer project and return options when project is not provided and there is a project at the cwd', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + setCwd('apps/app1'); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/src/app/my-component', + fileName: 'my-component', + filePath: 'apps/app1/src/app/my-component/my-component.ts', + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + + restoreCwd(); + }); + + it('should support receiving a directory correctly under the inferred project root', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + directory: 'apps/app1', + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/my-component', + fileName: 'my-component', + filePath: 'apps/app1/my-component/my-component.ts', + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it(`should handle window's style paths correctly`, async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + directory: 'apps\\app1', + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/my-component', + fileName: 'my-component', + filePath: 'apps/app1/my-component/my-component.ts', + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should support receiving a project', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/src/app/my-component', + fileName: 'my-component', + filePath: 'apps/app1/src/app/my-component/my-component.ts', + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should throw when the provided directory is not under the provided project root', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + await expect( + determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app1', + directory: 'foo/bar', + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The provided directory "foo/bar" is not under the provided project root "apps/app1". Please provide a directory that is under the provided project root or use the "as-provided" format and only provide the directory."` + ); + }); + + it('should support receiving a path as the name', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'foo/bar/myComponent', + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/src/app/foo/bar/my-component', + fileName: 'my-component', + filePath: 'apps/app1/src/app/foo/bar/my-component/my-component.ts', + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should throw when `--disallowPathInNameForDerived` and receiving a path as the name', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + await expect( + determineArtifactNameAndDirectoryOptions(tree, { + name: 'apps/app1/foo/bar/myComponent', + disallowPathInNameForDerived: true, + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"The provided name "apps/app1/foo/bar/myComponent" contains a path and this is not supported by the "@my-org/my-plugin:component" when using the "derived" format. Please provide a name without a path or use the "as-provided" format."` + ); + }); + + it('should support "--flat"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + flat: true, + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/src/app', + fileName: 'my-component', + filePath: 'apps/app1/src/app/my-component.ts', + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should support receiving a suffix', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + suffix: 'component', + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/src/app/my-component', + fileName: 'my-component.component', + filePath: 'apps/app1/src/app/my-component/my-component.component.ts', + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should support receiving a fileName', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + fileName: 'myComponent.component', + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/src/app/my-component', + fileName: 'myComponent.component', + filePath: 'apps/app1/src/app/my-component/myComponent.component.ts', + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should support "--pascalCaseFile"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + pascalCaseFile: true, + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/src/app/my-component', + fileName: 'MyComponent', + filePath: 'apps/app1/src/app/my-component/MyComponent.ts', + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should support "--pascalCaseDirectory"', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + pascalCaseDirectory: true, + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/src/app/MyComponent', + fileName: 'my-component', + filePath: 'apps/app1/src/app/MyComponent/my-component.ts', + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + + it('should support receiving a different file extension', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + fileExtension: 'tsx', + project: 'app1', + nameAndDirectoryFormat: 'derived', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(result).toStrictEqual({ + artifactName: 'myComponent', + directory: 'apps/app1/src/app/my-component', + fileName: 'my-component', + filePath: 'apps/app1/src/app/my-component/my-component.tsx', + project: 'app1', + nameAndDirectoryFormat: 'derived', + }); + }); + }); + + describe('no format', () => { + it('should prompt for the format to use', async () => { + // simulate interactive mode + ensureInteractiveMode(); + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + const promptSpy = jest + .spyOn(enquirer, 'prompt') + .mockImplementation(() => Promise.resolve({ format: 'as-provided' })); + + await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app1', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(promptSpy).toHaveBeenCalled(); + const promptCallOptions = promptSpy.mock.calls[0][0] as any; + expect(promptCallOptions.choices).toStrictEqual([ + { + message: 'As provided: myComponent.ts', + // as-provided ignores the project and uses cwd + directory + // in this case, both are empty + name: 'myComponent.ts', + }, + { + message: + 'Derived: apps/app1/src/app/my-component/my-component.ts', + name: 'apps/app1/src/app/my-component/my-component.ts', + }, + ]); + + // restore original interactive mode + restoreOriginalInteractiveMode(); + }); + + it('should not prompt and default to "derived" format when running in a non-interactive env', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + const promptSpy = jest.spyOn(enquirer, 'prompt'); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app1', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(result.nameAndDirectoryFormat).toBe('derived'); + }); + + it('should not prompt and default to "as-provided" format when providing a directory in the name is disallowed', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + const promptSpy = jest.spyOn(enquirer, 'prompt'); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'apps/app1/myComponent', + project: 'app1', + disallowPathInNameForDerived: true, + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(result.nameAndDirectoryFormat).toBe('as-provided'); + }); + + it('should not prompt and default to "as-provided" format when the directory is not under the provided project root', async () => { + addProjectConfiguration(tree, 'app1', { + root: 'apps/app1', + projectType: 'application', + }); + addProjectConfiguration(tree, 'app2', { + root: 'apps/app2', + projectType: 'application', + }); + const promptSpy = jest.spyOn(enquirer, 'prompt'); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'myComponent', + project: 'app1', + directory: 'apps/app2', + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(result.nameAndDirectoryFormat).toBe('as-provided'); + }); + + it('should not prompt when the resulting name and directory are the same for both formats', async () => { + // simulate interactive mode + ensureInteractiveMode(); + addProjectConfiguration(tree, 'app1', { + root: '.', + projectType: 'application', + }); + const promptSpy = jest.spyOn(enquirer, 'prompt'); + + const result = await determineArtifactNameAndDirectoryOptions(tree, { + name: 'my-component', + directory: 'src/app', + flat: true, + artifactType: 'component', + callingGenerator: '@my-org/my-plugin:component', + }); + + expect(promptSpy).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + artifactName: 'my-component', + directory: 'src/app', + fileName: 'my-component', + filePath: 'src/app/my-component.ts', + project: 'app1', + nameAndDirectoryFormat: 'as-provided', + }); + + // restore original interactive mode + restoreOriginalInteractiveMode(); + }); + }); +}); diff --git a/packages/devkit/src/generators/artifact-name-and-directory-utils.ts b/packages/devkit/src/generators/artifact-name-and-directory-utils.ts new file mode 100644 index 0000000000000..faa34888e5692 --- /dev/null +++ b/packages/devkit/src/generators/artifact-name-and-directory-utils.ts @@ -0,0 +1,410 @@ +import { prompt } from 'enquirer'; +import type { ProjectConfiguration } from 'nx/src/config/workspace-json-project-json'; +import type { Tree } from 'nx/src/generators/tree'; +import { relative } from 'path'; +import { requireNx } from '../../nx'; +import { names } from '../utils/names'; + +const { + createProjectRootMappingsFromProjectConfigurations, + findProjectForPath, + getProjects, + joinPathFragments, + logger, + normalizePath, + output, + workspaceRoot, +} = requireNx(); + +export type NameAndDirectoryFormat = 'as-provided' | 'derived'; +export type ArtifactGenerationOptions = { + artifactType: string; + callingGenerator: string | null; + name: string; + directory?: string; + disallowPathInNameForDerived?: boolean; + fileExtension?: 'js' | 'jsx' | 'ts' | 'tsx'; + fileName?: string; + flat?: boolean; + nameAndDirectoryFormat?: NameAndDirectoryFormat; + pascalCaseDirectory?: boolean; + pascalCaseFile?: boolean; + project?: string; + suffix?: string; +}; + +export type NameAndDirectoryOptions = { + /** + * Normalized artifact name. + */ + artifactName: string; + /** + * Normalized directory path where the artifact will be generated. + */ + directory: string; + /** + * Normalized file name of the artifact without the extension. + */ + fileName: string; + /** + * Normalized full file path of the artifact. + */ + filePath: string; + /** + * Project name where the artifact will be generated. + */ + project: string; +}; + +type NameAndDirectoryFormats = { + 'as-provided': NameAndDirectoryOptions; + derived: NameAndDirectoryOptions | undefined; +}; + +export async function determineArtifactNameAndDirectoryOptions( + tree: Tree, + options: ArtifactGenerationOptions +): Promise< + NameAndDirectoryOptions & { + nameAndDirectoryFormat: NameAndDirectoryFormat; + } +> { + const formats = getNameAndDirectoryOptionFormats(tree, options); + const format = + options.nameAndDirectoryFormat ?? (await determineFormat(formats, options)); + + validateResolvedProject( + formats[format]?.project, + options, + formats[format]?.directory + ); + + return { + ...formats[format], + nameAndDirectoryFormat: format, + }; +} + +async function determineFormat( + formats: NameAndDirectoryFormats, + options: ArtifactGenerationOptions +): Promise { + if (!formats.derived) { + return 'as-provided'; + } + + if (process.env.NX_INTERACTIVE !== 'true' || !isTTY()) { + logDeprecationMessage(options, formats); + + return 'derived'; + } + + const asProvidedDescription = `As provided: ${formats['as-provided'].filePath}`; + const asProvidedSelectedValue = formats['as-provided'].filePath; + const derivedDescription = `Derived: ${formats['derived'].filePath}`; + const derivedSelectedValue = formats['derived'].filePath; + + if (asProvidedSelectedValue === derivedSelectedValue) { + return 'as-provided'; + } + + const result = await prompt<{ format: NameAndDirectoryFormat }>({ + type: 'select', + name: 'format', + message: `Where should the ${options.artifactType} be generated?`, + choices: [ + { + message: asProvidedDescription, + name: asProvidedSelectedValue, + }, + { + message: derivedDescription, + name: derivedSelectedValue, + }, + ], + initial: 'as-provided' as any, + }).then(({ format }) => + format === asProvidedSelectedValue ? 'as-provided' : 'derived' + ); + + if (result === 'derived' && options.callingGenerator) { + logDeprecationMessage(options, formats); + } + + return result; +} + +function logDeprecationMessage( + options: ArtifactGenerationOptions, + formats: NameAndDirectoryFormats +) { + logger.warn(` +In Nx 18, generating a ${options.artifactType} will no longer support providing a project and deriving the directory. +Please provide the exact directory in the future. +Example: nx g ${options.callingGenerator} ${formats['derived'].artifactName} --directory ${formats['derived'].directory} +NOTE: The example above assumes the command is being run from the workspace root. If the command is being run from a subdirectory, the directory option should be adjusted accordingly. +`); +} + +function getNameAndDirectoryOptionFormats( + tree: Tree, + options: ArtifactGenerationOptions +): NameAndDirectoryFormats { + const directory = options.directory + ? normalizePath(options.directory.replace(/^\.?\//, '')) + : undefined; + const fileExtension = options.fileExtension ?? 'ts'; + const { name: extractedName, directory: extractedDirectory } = + extractNameAndDirectoryFromName(options.name); + + if (extractedDirectory && directory) { + throw new Error( + `You can't specify both a directory (${options.directory}) and a name with a directory path (${options.name}). ` + + `Please specify either a directory or a name with a directory path.` + ); + } + + const asProvidedOptions = getAsProvidedOptions(tree, { + ...options, + directory: directory ?? extractedDirectory, + fileExtension, + name: extractedName, + }); + + if (!options.project) { + validateResolvedProject( + asProvidedOptions.project, + options, + asProvidedOptions.directory + ); + } + + if (options.nameAndDirectoryFormat === 'as-provided') { + return { + 'as-provided': asProvidedOptions, + derived: undefined, + }; + } + + if (options.disallowPathInNameForDerived && options.name.includes('/')) { + if (!options.nameAndDirectoryFormat) { + output.warn({ + title: `The provided name "${options.name}" contains a path and this is not supported by the "${options.callingGenerator}" when using the "derived" format.`, + bodyLines: [ + `The generator will try to generate the ${options.artifactType} using the "as-provided" format at "${asProvidedOptions.filePath}".`, + ], + }); + + return { + 'as-provided': asProvidedOptions, + derived: undefined, + }; + } + + throw new Error( + `The provided name "${options.name}" contains a path and this is not supported by the "${options.callingGenerator}" when using the "derived" format. ` + + `Please provide a name without a path or use the "as-provided" format.` + ); + } + + const derivedOptions = getDerivedOptions( + tree, + { + ...options, + directory, + fileExtension, + name: extractedName, + }, + asProvidedOptions, + !options.disallowPathInNameForDerived && extractedDirectory + ? extractedDirectory + : undefined + ); + + return { + 'as-provided': asProvidedOptions, + derived: derivedOptions, + }; +} + +function getAsProvidedOptions( + tree: Tree, + options: ArtifactGenerationOptions +): NameAndDirectoryOptions { + const relativeCwd = getRelativeCwd(); + + const asProvidedDirectory = options.directory + ? joinPathFragments(relativeCwd, options.directory) + : relativeCwd; + const asProvidedProject = findProjectFromPath(tree, asProvidedDirectory); + + const asProvidedFileName = + options.fileName ?? + (options.suffix ? `${options.name}.${options.suffix}` : options.name); + const asProvidedFilePath = joinPathFragments( + asProvidedDirectory, + `${asProvidedFileName}.${options.fileExtension}` + ); + + return { + artifactName: options.name, + directory: asProvidedDirectory, + fileName: asProvidedFileName, + filePath: asProvidedFilePath, + project: asProvidedProject, + }; +} + +function getDerivedOptions( + tree: Tree, + options: ArtifactGenerationOptions, + asProvidedOptions: NameAndDirectoryOptions, + extractedDirectory: string | undefined +): NameAndDirectoryOptions | undefined { + const projects = getProjects(tree); + if (options.project && !projects.has(options.project)) { + throw new Error( + `The provided project "${options.project}" does not exist! Please provide an existing project name.` + ); + } + + const projectName = options.project ?? asProvidedOptions.project; + const project = projects.get(projectName); + const derivedName = options.name; + const baseDirectory = options.directory + ? names(options.directory).fileName + : joinPathFragments( + project.sourceRoot ?? joinPathFragments(project.root, 'src'), + project.projectType === 'application' ? 'app' : 'lib', + extractedDirectory ?? '' + ); + const derivedDirectory = options.flat + ? normalizePath(baseDirectory) + : joinPathFragments( + baseDirectory, + options.pascalCaseDirectory + ? names(derivedName).className + : names(derivedName).fileName + ); + + if ( + options.directory && + !isDirectoryUnderProjectRoot(derivedDirectory, project.root) + ) { + if (!options.nameAndDirectoryFormat) { + output.warn({ + title: `The provided directory "${options.directory}" is not under the provided project root "${project.root}".`, + bodyLines: [ + `The generator will try to generate the ${options.artifactType} using the "as-provided" format.`, + `With the "as-provided" format, the "project" option is ignored and the ${options.artifactType} will be generated at "${asProvidedOptions.filePath}" (/).`, + ], + }); + + return undefined; + } + + throw new Error( + `The provided directory "${options.directory}" is not under the provided project root "${project.root}". ` + + `Please provide a directory that is under the provided project root or use the "as-provided" format and only provide the directory.` + ); + } + + let derivedFileName = options.fileName; + if (!derivedFileName) { + derivedFileName = options.suffix + ? `${derivedName}.${options.suffix}` + : derivedName; + derivedFileName = options.pascalCaseFile + ? names(derivedFileName).className + : names(derivedFileName).fileName; + } + const derivedFilePath = joinPathFragments( + derivedDirectory, + `${derivedFileName}.${options.fileExtension}` + ); + + return { + artifactName: derivedName, + directory: derivedDirectory, + fileName: derivedFileName, + filePath: derivedFilePath, + project: projectName, + }; +} + +function validateResolvedProject( + project: string | undefined, + options: ArtifactGenerationOptions, + normalizedDirectory: string +): void { + if (project) { + return; + } + + if (options.directory) { + throw new Error( + `The provided directory resolved relative to the current working directory "${normalizedDirectory}" does not exist under any project root. ` + + `Please make sure to navigate to a location or provide a directory that exists under a project root.` + ); + } + + throw new Error( + `The current working directory "${ + getRelativeCwd() || '.' + }" does not exist under any project root. ` + + `Please make sure to navigate to a location or provide a directory that exists under a project root.` + ); +} + +function findProjectFromPath(tree: Tree, path: string): string | null { + const projectConfigurations: Record = {}; + const projects = getProjects(tree); + for (const [projectName, project] of projects) { + projectConfigurations[projectName] = project; + } + const projectRootMappings = + createProjectRootMappingsFromProjectConfigurations(projectConfigurations); + + return findProjectForPath(path, projectRootMappings); +} + +function isDirectoryUnderProjectRoot( + directory: string, + projectRoot: string +): boolean { + const normalizedDirectory = joinPathFragments(workspaceRoot, directory); + const normalizedProjectRoot = joinPathFragments( + workspaceRoot, + projectRoot + ).replace(/\/$/, ''); + + return ( + normalizedDirectory === normalizedProjectRoot || + normalizedDirectory.startsWith(`${normalizedProjectRoot}/`) + ); +} + +function isTTY(): boolean { + return !!process.stdout.isTTY && process.env['CI'] !== 'true'; +} + +function getRelativeCwd(): string { + return normalizePath(relative(workspaceRoot, getCwd())); +} + +function getCwd(): string { + return process.env.INIT_CWD?.startsWith(workspaceRoot) + ? process.env.INIT_CWD + : process.cwd(); +} + +function extractNameAndDirectoryFromName(rawName: string): { + name: string; + directory: string | undefined; +} { + const parsedName = normalizePath(rawName).split('/'); + const name = parsedName.pop(); + const directory = parsedName.length ? parsedName.join('/') : undefined; + + return { name, directory }; +} diff --git a/packages/nx/src/devkit-internals.ts b/packages/nx/src/devkit-internals.ts index cd12d71e36086..ed7d2be4e3af5 100644 --- a/packages/nx/src/devkit-internals.ts +++ b/packages/nx/src/devkit-internals.ts @@ -14,3 +14,7 @@ export { sortObjectByKeys } from './utils/object-sort'; export { stripIndent } from './utils/logger'; export { readModulePackageJson } from './utils/package-json'; export { splitByColons } from './utils/split-target'; +export { + createProjectRootMappingsFromProjectConfigurations, + findProjectForPath, +} from './project-graph/utils/find-project-for-path'; diff --git a/packages/nx/src/utils/params.ts b/packages/nx/src/utils/params.ts index 1488ff3e7d2e5..7fb629e79b852 100644 --- a/packages/nx/src/utils/params.ts +++ b/packages/nx/src/utils/params.ts @@ -633,6 +633,8 @@ export async function combineOptionsForGenerator( schema, false ); + + warnDeprecations(combined, schema); convertSmartDefaultsIntoNamedParams( combined, schema, @@ -644,9 +646,7 @@ export async function combineOptionsForGenerator( combined = await promptForValues(combined, schema, projectsConfigurations); } - warnDeprecations(combined, schema); setDefaults(combined, schema); - validateOptsAgainstSchema(combined, schema); applyVerbosity(combined, schema, isVerbose); return combined;