Skip to content

Commit 3136903

Browse files
committed
fix(@schematics/angular): add injectable schematic
Adds a schematic to generate an `@Injectable` after `service` was re-purposed.
1 parent da090f9 commit 3136903

6 files changed

Lines changed: 255 additions & 0 deletions

File tree

packages/schematics/angular/collection.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@
6464
"description": "Create an interceptor.",
6565
"schema": "./interceptor/schema.json"
6666
},
67+
"injectable": {
68+
"factory": "./injectable",
69+
"description": "Create an Angular injectable.",
70+
"schema": "./injectable/schema.json"
71+
},
6772
"interface": {
6873
"aliases": ["i"],
6974
"factory": "./interface",
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { TestBed } from '@angular/core/testing';
2+
3+
import { <%= classifiedName %> } from './<%= dasherize(name) %><%= type ? '.' + dasherize(type) : '' %>';
4+
5+
describe('<%= classifiedName %>', () => {
6+
let service: <%= classifiedName %>;
7+
8+
beforeEach(() => {
9+
TestBed.configureTestingModule({});
10+
service = TestBed.inject(<%= classifiedName %>);
11+
});
12+
13+
it('should be created', () => {
14+
expect(service).toBeTruthy();
15+
});
16+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Injectable } from '@angular/core';
2+
3+
@Injectable({
4+
providedIn: 'root',
5+
})
6+
export class <%= classifiedName %> {
7+
8+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { RuleFactory, strings } from '@angular-devkit/schematics';
10+
import { generateFromFiles } from '../utility/generate-from-files';
11+
import { parseName } from '../utility/parse-name';
12+
import { createProjectSchematic } from '../utility/project';
13+
import { validateClassName } from '../utility/validation';
14+
import { buildDefaultPath } from '../utility/workspace';
15+
import { Schema as ServiceOptions } from './schema';
16+
17+
const serviceSchematic: RuleFactory<ServiceOptions> = createProjectSchematic(
18+
(options, { project, tree }) => {
19+
if (options.path === undefined) {
20+
options.path = buildDefaultPath(project);
21+
}
22+
23+
const parsedPath = parseName(options.path, options.name);
24+
options.name = parsedPath.name;
25+
options.path = parsedPath.path;
26+
27+
const classifiedName =
28+
strings.classify(options.name) +
29+
(options.addTypeToClassName && options.type ? strings.classify(options.type) : '');
30+
validateClassName(classifiedName);
31+
32+
return generateFromFiles({
33+
...options,
34+
classifiedName,
35+
});
36+
},
37+
);
38+
39+
export default serviceSchematic;
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
10+
import { Schema as ApplicationOptions } from '../application/schema';
11+
import { Schema as WorkspaceOptions } from '../workspace/schema';
12+
import { Schema as ServiceOptions } from './schema';
13+
14+
describe('Injectable Schematic', () => {
15+
const schematicRunner = new SchematicTestRunner(
16+
'@schematics/angular',
17+
require.resolve('../collection.json'),
18+
);
19+
const defaultOptions: ServiceOptions = {
20+
name: 'foo',
21+
flat: false,
22+
project: 'bar',
23+
};
24+
25+
const workspaceOptions: WorkspaceOptions = {
26+
name: 'workspace',
27+
newProjectRoot: 'projects',
28+
version: '6.0.0',
29+
};
30+
31+
const appOptions: ApplicationOptions = {
32+
name: 'bar',
33+
inlineStyle: false,
34+
inlineTemplate: false,
35+
routing: false,
36+
skipPackageJson: false,
37+
};
38+
let appTree: UnitTestTree;
39+
beforeEach(async () => {
40+
appTree = await schematicRunner.runSchematic('workspace', workspaceOptions);
41+
appTree = await schematicRunner.runSchematic('application', appOptions, appTree);
42+
});
43+
44+
it('should create an injectable', async () => {
45+
const options = { ...defaultOptions };
46+
47+
const tree = await schematicRunner.runSchematic('injectable', options, appTree);
48+
const files = tree.files;
49+
expect(files).toContain('/projects/bar/src/app/foo/foo.spec.ts');
50+
expect(files).toContain('/projects/bar/src/app/foo/foo.ts');
51+
});
52+
53+
it('should use @Injectable decorator', async () => {
54+
const options = { ...defaultOptions };
55+
56+
const tree = await schematicRunner.runSchematic('injectable', options, appTree);
57+
const content = tree.readContent('/projects/bar/src/app/foo/foo.ts');
58+
expect(content).toMatch(/@Injectable\(\)/);
59+
expect(content).toMatch(/import \{ Injectable \} from '@angular\/core'/);
60+
});
61+
62+
it('injectable should be tree-shakeable', async () => {
63+
const options = { ...defaultOptions };
64+
65+
const tree = await schematicRunner.runSchematic('injectable', options, appTree);
66+
const content = tree.readContent('/projects/bar/src/app/foo/foo.ts');
67+
expect(content).toMatch(/providedIn: 'root',/);
68+
});
69+
70+
it('should respect the skipTests flag', async () => {
71+
const options = { ...defaultOptions, skipTests: true };
72+
73+
const tree = await schematicRunner.runSchematic('injectable', options, appTree);
74+
const files = tree.files;
75+
expect(files).toContain('/projects/bar/src/app/foo/foo.ts');
76+
expect(files).not.toContain('/projects/bar/src/app/foo/foo.spec.ts');
77+
});
78+
79+
it('should respect the sourceRoot value', async () => {
80+
const config = JSON.parse(appTree.readContent('/angular.json'));
81+
config.projects.bar.sourceRoot = 'projects/bar/custom';
82+
appTree.overwrite('/angular.json', JSON.stringify(config, null, 2));
83+
appTree = await schematicRunner.runSchematic('injectable', defaultOptions, appTree);
84+
expect(appTree.files).toContain('/projects/bar/custom/app/foo/foo.ts');
85+
});
86+
87+
it('should respect the type option', async () => {
88+
const options = { ...defaultOptions, type: 'Service' };
89+
const tree = await schematicRunner.runSchematic('injectable', options, appTree);
90+
const content = tree.readContent('/projects/bar/src/app/foo/foo.service.ts');
91+
const testContent = tree.readContent('/projects/bar/src/app/foo/foo.service.spec.ts');
92+
expect(content).toContain('export class FooService');
93+
expect(testContent).toContain("describe('FooService'");
94+
});
95+
96+
it('should allow empty string in the type option', async () => {
97+
const options = { ...defaultOptions, type: '' };
98+
const tree = await schematicRunner.runSchematic('injectable', options, appTree);
99+
const content = tree.readContent('/projects/bar/src/app/foo/foo.ts');
100+
const testContent = tree.readContent('/projects/bar/src/app/foo/foo.spec.ts');
101+
expect(content).toContain('export class Foo');
102+
expect(testContent).toContain("describe('Foo'");
103+
});
104+
105+
it('should not add type to class name when addTypeToClassName is false', async () => {
106+
const options = { ...defaultOptions, type: 'Service', addTypeToClassName: false };
107+
const tree = await schematicRunner.runSchematic('injectable', options, appTree);
108+
const content = tree.readContent('/projects/bar/src/app/foo/foo.service.ts');
109+
const testContent = tree.readContent('/projects/bar/src/app/foo/foo.service.spec.ts');
110+
expect(content).toContain('export class Foo {');
111+
expect(content).not.toContain('export class FooService {');
112+
expect(testContent).toContain("describe('Foo', () => {");
113+
expect(testContent).not.toContain("describe('FooService', () => {");
114+
});
115+
116+
it('should add type to class name when addTypeToClassName is true', async () => {
117+
const options = { ...defaultOptions, type: 'Service', addTypeToClassName: true };
118+
const tree = await schematicRunner.runSchematic('injectable', options, appTree);
119+
const content = tree.readContent('/projects/bar/src/app/foo/foo.service.ts');
120+
const testContent = tree.readContent('/projects/bar/src/app/foo/foo.service.spec.ts');
121+
expect(content).toContain('export class FooService {');
122+
expect(testContent).toContain("describe('FooService', () => {");
123+
});
124+
125+
it('should add type to class name by default', async () => {
126+
const options = { ...defaultOptions, type: 'Service', addTypeToClassName: undefined };
127+
const tree = await schematicRunner.runSchematic('injectable', options, appTree);
128+
const content = tree.readContent('/projects/bar/src/app/foo/foo.service.ts');
129+
const testContent = tree.readContent('/projects/bar/src/app/foo/foo.service.spec.ts');
130+
expect(content).toContain('export class FooService {');
131+
expect(testContent).toContain("describe('FooService', () => {");
132+
});
133+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"$id": "SchematicsAngularService",
4+
"title": "Angular Service Options Schema",
5+
"type": "object",
6+
"additionalProperties": false,
7+
"description": "Creates a new service in your project. Services are used to encapsulate reusable logic, such as data access, API calls, or utility functions. This schematic simplifies the process of generating a new service with the necessary files and boilerplate code.",
8+
"properties": {
9+
"name": {
10+
"type": "string",
11+
"description": "The name for the new service. This will be used to create the service's class and spec files (e.g., `my-service.service.ts` and `my-service.service.spec.ts`).",
12+
"$default": {
13+
"$source": "argv",
14+
"index": 0
15+
},
16+
"x-prompt": "What name would you like to use for the service?"
17+
},
18+
"path": {
19+
"type": "string",
20+
"$default": {
21+
"$source": "workingDirectory"
22+
},
23+
"description": "The path where the service files should be created, relative to the workspace root. If not provided, the service will be created in the project's `src/app` directory.",
24+
"visible": false
25+
},
26+
"project": {
27+
"type": "string",
28+
"description": "The name of the project where the service should be added. If not specified, the CLI will determine the project from the current directory.",
29+
"$default": {
30+
"$source": "projectName"
31+
}
32+
},
33+
"flat": {
34+
"type": "boolean",
35+
"default": true,
36+
"description": "Creates files at the top level of the project or the given path. If set to false, a new folder with the service's name will be created to contain the files."
37+
},
38+
"skipTests": {
39+
"type": "boolean",
40+
"description": "Skip the generation of a unit test file `spec.ts` for the service.",
41+
"default": false
42+
},
43+
"type": {
44+
"type": "string",
45+
"description": "Append a custom type to the service's filename. For example, if you set the type to `service`, the file will be named `my-service.service.ts`."
46+
},
47+
"addTypeToClassName": {
48+
"type": "boolean",
49+
"default": true,
50+
"description": "When true, the 'type' option will be appended to the generated class name. When false, only the file name will include the type."
51+
}
52+
},
53+
"required": ["name", "project"]
54+
}

0 commit comments

Comments
 (0)