Skip to content

fix(@schematics/angular): generate directives without a .directive extension/type #29893

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { <%= classify(name) %><%= classify(type) %> } from './<%= dasherize(name) %><%= type ? '.' + dasherize(type) : '' %>';

describe('<%= classify(name) %><%= classify(type) %>', () => {
it('should create an instance', () => {
const directive = new <%= classify(name) %><%= classify(type) %>();
expect(directive).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Directive } from '@angular/core';
selector: '[<%= selector %>]'<% if(!standalone) {%>,
standalone: false<%}%>
})
export class <%= classify(name) %>Directive {
export class <%= classify(name) %><%= classify(type) %> {

constructor() { }

Expand Down
31 changes: 6 additions & 25 deletions packages/schematics/angular/directive/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,10 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {
Rule,
SchematicsException,
Tree,
apply,
applyTemplates,
chain,
filter,
mergeWith,
move,
noop,
strings,
url,
} from '@angular-devkit/schematics';
import { Rule, SchematicsException, Tree, chain, strings } from '@angular-devkit/schematics';
import { addDeclarationToNgModule } from '../utility/add-declaration-to-ng-module';
import { findModuleFromOptions } from '../utility/find-module';
import { generateFromFiles } from '../utility/generate-from-files';
import { parseName } from '../utility/parse-name';
import { validateClassName, validateHtmlSelector } from '../utility/validation';
import { buildDefaultPath, getWorkspace } from '../utility/workspace';
Expand Down Expand Up @@ -52,6 +40,9 @@ export default function (options: DirectiveOptions): Rule {

options.module = findModuleFromOptions(host, options);

// Schematic templates require a defined type value
options.type ??= '';

const parsedPath = parseName(options.path, options.name);
options.name = parsedPath.name;
options.path = parsedPath.path;
Expand All @@ -60,23 +51,13 @@ export default function (options: DirectiveOptions): Rule {
validateHtmlSelector(options.selector);
validateClassName(strings.classify(options.name));

const templateSource = apply(url('./files'), [
options.skipTests ? filter((path) => !path.endsWith('.spec.ts.template')) : noop(),
applyTemplates({
...strings,
'if-flat': (s: string) => (options.flat ? '' : s),
...options,
}),
move(parsedPath.path),
]);

return chain([
addDeclarationToNgModule({
type: 'directive',

...options,
}),
mergeWith(templateSource),
generateFromFiles(options),
]);
};
}
56 changes: 37 additions & 19 deletions packages/schematics/angular/directive/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,47 +50,47 @@ describe('Directive Schematic', () => {

const tree = await schematicRunner.runSchematic('directive', options, appTree);
const files = tree.files;
expect(files).toContain('/projects/bar/src/app/foo/foo.directive.spec.ts');
expect(files).toContain('/projects/bar/src/app/foo/foo.directive.ts');
expect(files).toContain('/projects/bar/src/app/foo/foo.spec.ts');
expect(files).toContain('/projects/bar/src/app/foo/foo.ts');
});

it('should converts dash-cased-name to a camelCasedSelector', async () => {
const options = { ...defaultOptions, name: 'my-dir' };

const tree = await schematicRunner.runSchematic('directive', options, appTree);
const content = tree.readContent('/projects/bar/src/app/my-dir.directive.ts');
const content = tree.readContent('/projects/bar/src/app/my-dir.ts');
expect(content).toMatch(/selector: '\[appMyDir\]'/);
});

it('should create the right selector with a path in the name', async () => {
const options = { ...defaultOptions, name: 'sub/test' };
appTree = await schematicRunner.runSchematic('directive', options, appTree);

const content = appTree.readContent('/projects/bar/src/app/sub/test.directive.ts');
const content = appTree.readContent('/projects/bar/src/app/sub/test.ts');
expect(content).toMatch(/selector: '\[appTest\]'/);
});

it('should use the prefix', async () => {
const options = { ...defaultOptions, prefix: 'pre' };
const tree = await schematicRunner.runSchematic('directive', options, appTree);

const content = tree.readContent('/projects/bar/src/app/foo.directive.ts');
const content = tree.readContent('/projects/bar/src/app/foo.ts');
expect(content).toMatch(/selector: '\[preFoo\]'/);
});

it('should use the default project prefix if none is passed', async () => {
const options = { ...defaultOptions, prefix: undefined };
const tree = await schematicRunner.runSchematic('directive', options, appTree);

const content = tree.readContent('/projects/bar/src/app/foo.directive.ts');
const content = tree.readContent('/projects/bar/src/app/foo.ts');
expect(content).toMatch(/selector: '\[appFoo\]'/);
});

it('should use the supplied prefix if it is ""', async () => {
const options = { ...defaultOptions, prefix: '' };
const tree = await schematicRunner.runSchematic('directive', options, appTree);

const content = tree.readContent('/projects/bar/src/app/foo.directive.ts');
const content = tree.readContent('/projects/bar/src/app/foo.ts');
expect(content).toMatch(/selector: '\[foo\]'/);
});

Expand All @@ -99,16 +99,16 @@ describe('Directive Schematic', () => {

const tree = await schematicRunner.runSchematic('directive', options, appTree);
const files = tree.files;
expect(files).toContain('/projects/bar/src/app/foo.directive.ts');
expect(files).not.toContain('/projects/bar/src/app/foo.directive.spec.ts');
expect(files).toContain('/projects/bar/src/app/foo.ts');
expect(files).not.toContain('/projects/bar/src/app/foo.spec.ts');
});

it('should create a standalone directive', async () => {
const options = { ...defaultOptions, standalone: true };
const tree = await schematicRunner.runSchematic('directive', options, appTree);
const directiveContent = tree.readContent('/projects/bar/src/app/foo.directive.ts');
const directiveContent = tree.readContent('/projects/bar/src/app/foo.ts');
expect(directiveContent).not.toContain('standalone');
expect(directiveContent).toContain('class FooDirective');
expect(directiveContent).toContain('class Foo');
});

it('should error when class name contains invalid characters', async () => {
Expand All @@ -119,6 +119,24 @@ describe('Directive Schematic', () => {
).toBeRejectedWithError('Class name "404" is invalid.');
});

it('should respect the type option', async () => {
const options = { ...defaultOptions, type: 'Directive' };
const tree = await schematicRunner.runSchematic('directive', options, appTree);
const content = tree.readContent('/projects/bar/src/app/foo.directive.ts');
const testContent = tree.readContent('/projects/bar/src/app/foo.directive.spec.ts');
expect(content).toContain('export class FooDirective');
expect(testContent).toContain("describe('FooDirective'");
});

it('should allow empty string in the type option', async () => {
const options = { ...defaultOptions, type: '' };
const tree = await schematicRunner.runSchematic('directive', options, appTree);
const content = tree.readContent('/projects/bar/src/app/foo.ts');
const testContent = tree.readContent('/projects/bar/src/app/foo.spec.ts');
expect(content).toContain('export class Foo');
expect(testContent).toContain("describe('Foo'");
});

describe('standalone=false', () => {
const defaultNonStandaloneOptions: DirectiveOptions = {
...defaultOptions,
Expand All @@ -139,11 +157,11 @@ describe('Directive Schematic', () => {

const tree = await schematicRunner.runSchematic('directive', options, appTree);
const files = tree.files;
expect(files).toContain('/projects/baz/src/app/foo.directive.spec.ts');
expect(files).toContain('/projects/baz/src/app/foo.directive.ts');
expect(files).toContain('/projects/baz/src/app/foo.spec.ts');
expect(files).toContain('/projects/baz/src/app/foo.ts');
const moduleContent = tree.readContent('/projects/baz/src/app/app.module.ts');
expect(moduleContent).toMatch(/import.*Foo.*from '.\/foo.directive'/);
expect(moduleContent).toMatch(/declarations:\s*\[[^\]]+?,\r?\n\s+FooDirective\r?\n/m);
expect(moduleContent).toMatch(/import.*Foo.*from '.\/foo'/);
expect(moduleContent).toMatch(/declarations:\s*\[[^\]]+?,\r?\n\s+Foo\r?\n/m);
});

it('should respect the sourceRoot value', async () => {
Expand All @@ -167,7 +185,7 @@ describe('Directive Schematic', () => {
appTree,
);

expect(appTree.files).toContain('/projects/baz/custom/app/foo.directive.ts');
expect(appTree.files).toContain('/projects/baz/custom/app/foo.ts');
});

it('should find the closest module', async () => {
Expand All @@ -188,15 +206,15 @@ describe('Directive Schematic', () => {

const tree = await schematicRunner.runSchematic('directive', options, appTree);
const fooModuleContent = tree.readContent(fooModule);
expect(fooModuleContent).toMatch(/import { FooDirective } from '.\/foo.directive'/);
expect(fooModuleContent).toMatch(/import { Foo } from '.\/foo'/);
});

it('should export the directive', async () => {
const options = { ...defaultNonStandaloneOptions, export: true };

const tree = await schematicRunner.runSchematic('directive', options, appTree);
const appModuleContent = tree.readContent('/projects/baz/src/app/app.module.ts');
expect(appModuleContent).toMatch(/exports: \[\n(\s*) {2}FooDirective\n\1\]/);
expect(appModuleContent).toMatch(/exports: \[\n(\s*) {2}Foo\n\1\]/);
});

it('should import into a specified module', async () => {
Expand All @@ -205,7 +223,7 @@ describe('Directive Schematic', () => {
const tree = await schematicRunner.runSchematic('directive', options, appTree);
const appModule = tree.readContent('/projects/baz/src/app/app.module.ts');

expect(appModule).toMatch(/import { FooDirective } from '.\/foo.directive'/);
expect(appModule).toMatch(/import { Foo } from '.\/foo'/);
});

it('should fail if specified module does not exist', async () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/schematics/angular/directive/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@
"type": "boolean",
"default": false,
"description": "Automatically export the directive from the specified NgModule, making it accessible to other modules in the application."
},
"type": {
"type": "string",
"description": "Append a custom type to the directive's filename. For example, if you set the type to `directive`, the file will be named `example.directive.ts`."
}
},
"required": ["name", "project"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export default function () {
const directiveDir = join('src', 'app');
return (
ng('generate', 'directive', 'test-directive')
.then(() => expectFileToExist(join(directiveDir, 'test-directive.directive.ts')))
.then(() => expectFileToExist(join(directiveDir, 'test-directive.directive.spec.ts')))
.then(() => expectFileToExist(join(directiveDir, 'test-directive.ts')))
.then(() => expectFileToExist(join(directiveDir, 'test-directive.spec.ts')))

// Try to run the unit tests.
.then(() => ng('test', '--watch=false'))
Expand Down
13 changes: 3 additions & 10 deletions tests/legacy-cli/e2e/tests/generate/directive/directive-prefix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@ export default function () {
}),
)
.then(() => ng('generate', 'directive', 'test2-directive'))
.then(() =>
expectFileToMatch(join(directiveDir, 'test2-directive.directive.ts'), /selector: '\[preW/),
)
.then(() => expectFileToMatch(join(directiveDir, 'test2-directive.ts'), /selector: '\[preW/))
.then(() => ng('generate', 'application', 'app-two', '--skip-install'))
.then(() => useCIDefaults('app-two'))
.then(() => useCIChrome('app-two', './projects/app-two'))
Expand All @@ -33,17 +31,12 @@ export default function () {
.then(() => ng('generate', 'directive', '--skip-import', 'test3-directive'))
.then(() => process.chdir('../..'))
.then(() =>
expectFileToMatch(
join('projects', 'app-two', 'test3-directive.directive.ts'),
/selector: '\[preW/,
),
expectFileToMatch(join('projects', 'app-two', 'test3-directive.ts'), /selector: '\[preW/),
)
.then(() => process.chdir('src/app'))
.then(() => ng('generate', 'directive', 'test-directive'))
.then(() => process.chdir('../..'))
.then(() =>
expectFileToMatch(join(directiveDir, 'test-directive.directive.ts'), /selector: '\[preP/),
)
.then(() => expectFileToMatch(join(directiveDir, 'test-directive.ts'), /selector: '\[preP/))

// Try to run the unit tests.
.then(() => ng('test', '--watch=false'))
Expand Down