Skip to content

Commit 6c39a16

Browse files
atscottalan-agius4
authored andcommittedNov 9, 2022
feat(@schematics/angular): Add schematics for generating functional router guards and resolvers
Functional guards and resolvers were introduced in the Angular router in v14.2. This commit adds the ability to generate functional router guards by specifying `--guardType` instead of `--implements`. These guards are also accompanied by a test that includes a helper function for executing the guard in the `TestBed` environment so that any `inject` calls in the guard will work properly. Functional resolvers are generated by adding the `--functional` flag.
1 parent 827fecc commit 6c39a16

16 files changed

+186
-40
lines changed
 

‎packages/schematics/angular/BUILD.bazel

+8
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ ts_library(
4747
# Also exclude templated files.
4848
"*/files/**/*.ts",
4949
"*/other-files/**/*.ts",
50+
"*/implements-files/**/*",
51+
"*/type-files/**/*",
52+
"*/functional-files/**/*",
53+
"*/class-files/**/*",
5054
# Exclude test helpers.
5155
"utility/test/**/*.ts",
5256
# NB: we need to exclude the nested node_modules that is laid out by yarn workspaces
@@ -65,6 +69,10 @@ ts_library(
6569
"*/schema.json",
6670
"*/files/**/*",
6771
"*/other-files/**/*",
72+
"*/implements-files/**/*",
73+
"*/type-files/**/*",
74+
"*/functional-files/**/*",
75+
"*/class-files/**/*",
6876
],
6977
exclude = [
7078
# NB: we need to exclude the nested node_modules that is laid out by yarn workspaces

‎packages/schematics/angular/guard/files/__name@dasherize__.guard.ts.template ‎packages/schematics/angular/guard/implements-files/__name@dasherize__.guard.ts.template

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Injectable } from '@angular/core';
2-
import { <%= implementationImports %> } from '@angular/router';
2+
import { <%= routerImports %> } from '@angular/router';
33
import { Observable } from 'rxjs';
44

55
@Injectable({

‎packages/schematics/angular/guard/index.ts

+37-22
Original file line numberDiff line numberDiff line change
@@ -7,39 +7,54 @@
77
*/
88

99
import { Rule, SchematicsException } from '@angular-devkit/schematics';
10+
1011
import { generateFromFiles } from '../utility/generate-from-files';
12+
1113
import { Implement as GuardInterface, Schema as GuardOptions } from './schema';
1214

1315
export default function (options: GuardOptions): Rule {
14-
if (!options.implements) {
15-
throw new SchematicsException('Option "implements" is required.');
16+
if (options.implements && options.implements.length > 0 && options.guardType) {
17+
throw new SchematicsException('Options "implements" and "guardType" cannot be used together.');
1618
}
1719

18-
const implementations = options.implements
19-
.map((implement) => (implement === 'CanDeactivate' ? 'CanDeactivate<unknown>' : implement))
20-
.join(', ');
21-
const commonRouterNameImports = ['ActivatedRouteSnapshot', 'RouterStateSnapshot'];
22-
const routerNamedImports: string[] = [...options.implements, 'UrlTree'];
20+
if (options.guardType) {
21+
const guardType = options.guardType.replace(/^can/, 'Can') + 'Fn';
2322

24-
if (
25-
options.implements.includes(GuardInterface.CanLoad) ||
26-
options.implements.includes(GuardInterface.CanMatch)
27-
) {
28-
routerNamedImports.push('Route', 'UrlSegment');
23+
return generateFromFiles({ ...options, templateFilesDirectory: './type-files' }, { guardType });
24+
} else {
25+
if (!options.implements || options.implements.length < 1) {
26+
options.implements = [GuardInterface.CanActivate];
27+
}
2928

30-
if (options.implements.length > 1) {
29+
const implementations = options.implements
30+
.map((implement) => (implement === 'CanDeactivate' ? 'CanDeactivate<unknown>' : implement))
31+
.join(', ');
32+
const commonRouterNameImports = ['ActivatedRouteSnapshot', 'RouterStateSnapshot'];
33+
const routerNamedImports: string[] = [...options.implements, 'UrlTree'];
34+
35+
if (
36+
options.implements.includes(GuardInterface.CanLoad) ||
37+
options.implements.includes(GuardInterface.CanMatch)
38+
) {
39+
routerNamedImports.push('Route', 'UrlSegment');
40+
41+
if (options.implements.length > 1) {
42+
routerNamedImports.push(...commonRouterNameImports);
43+
}
44+
} else {
3145
routerNamedImports.push(...commonRouterNameImports);
3246
}
33-
} else {
34-
routerNamedImports.push(...commonRouterNameImports);
35-
}
3647

37-
routerNamedImports.sort();
48+
routerNamedImports.sort();
3849

39-
const implementationImports = routerNamedImports.join(', ');
50+
const routerImports = routerNamedImports.join(', ');
4051

41-
return generateFromFiles(options, {
42-
implementations,
43-
implementationImports,
44-
});
52+
return generateFromFiles(
53+
{ ...options, templateFilesDirectory: './implements-files' },
54+
{
55+
implementations,
56+
routerImports,
57+
},
58+
);
59+
}
4560
}

‎packages/schematics/angular/guard/index_spec.ts

+51-12
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
*/
88

99
import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';
10+
1011
import { Schema as ApplicationOptions } from '../application/schema';
1112
import { Schema as WorkspaceOptions } from '../workspace/schema';
13+
1214
import { Schema as GuardOptions } from './schema';
1315

1416
describe('Guard Schematic', () => {
@@ -90,6 +92,37 @@ describe('Guard Schematic', () => {
9092
expect(fileString).not.toContain('canLoad');
9193
});
9294

95+
it('should respect the guardType value', async () => {
96+
const options = { ...defaultOptions, guardType: 'canActivate' };
97+
const tree = await schematicRunner.runSchematicAsync('guard', options, appTree).toPromise();
98+
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts');
99+
expect(fileString).toContain('export const fooGuard: CanActivateFn = (route, state) => {');
100+
expect(fileString).not.toContain('CanActivateChild');
101+
expect(fileString).not.toContain('canActivateChild');
102+
expect(fileString).not.toContain('CanLoad');
103+
expect(fileString).not.toContain('canLoad');
104+
});
105+
106+
it('should generate a helper function to execute the guard in a test', async () => {
107+
const options = { ...defaultOptions, guardType: 'canActivate' };
108+
const tree = await schematicRunner.runSchematicAsync('guard', options, appTree).toPromise();
109+
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.spec.ts');
110+
expect(fileString).toContain('const executeGuard: CanActivateFn = (...guardParameters) => ');
111+
expect(fileString).toContain(
112+
'TestBed.inject(EnvironmentInjector).runInContext(() => fooGuard(...guardParameters));',
113+
);
114+
});
115+
116+
it('should generate CanDeactivateFn with unknown guardType', async () => {
117+
const options = { ...defaultOptions, guardType: 'canDeactivate' };
118+
const tree = await schematicRunner.runSchematicAsync('guard', options, appTree).toPromise();
119+
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts');
120+
expect(fileString).toContain(
121+
'export const fooGuard: CanDeactivateFn<unknown> = ' +
122+
'(component, currentRoute, currentState, nextState) => {',
123+
);
124+
});
125+
93126
it('should respect the implements values', async () => {
94127
const implementationOptions = ['CanActivate', 'CanLoad', 'CanActivateChild'];
95128
const options = { ...defaultOptions, implements: implementationOptions };
@@ -104,18 +137,6 @@ describe('Guard Schematic', () => {
104137
});
105138
});
106139

107-
it('should use CanActivate if no implements value', async () => {
108-
const options = { ...defaultOptions, implements: undefined };
109-
const tree = await schematicRunner.runSchematicAsync('guard', options, appTree).toPromise();
110-
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts');
111-
expect(fileString).toContain('CanActivate');
112-
expect(fileString).toContain('canActivate');
113-
expect(fileString).not.toContain('CanActivateChild');
114-
expect(fileString).not.toContain('canActivateChild');
115-
expect(fileString).not.toContain('CanLoad');
116-
expect(fileString).not.toContain('canLoad');
117-
});
118-
119140
it('should add correct imports based on CanLoad implementation', async () => {
120141
const implementationOptions = ['CanLoad'];
121142
const options = { ...defaultOptions, implements: implementationOptions };
@@ -136,6 +157,15 @@ describe('Guard Schematic', () => {
136157
expect(fileString).toContain(expectedImports);
137158
});
138159

160+
it('should add correct imports based on canLoad guardType', async () => {
161+
const options = { ...defaultOptions, guardType: 'canLoad' };
162+
const tree = await schematicRunner.runSchematicAsync('guard', options, appTree).toPromise();
163+
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts');
164+
const expectedImports = `import { CanLoadFn } from '@angular/router';`;
165+
166+
expect(fileString).toContain(expectedImports);
167+
});
168+
139169
it('should add correct imports based on CanActivate implementation', async () => {
140170
const implementationOptions = ['CanActivate'];
141171
const options = { ...defaultOptions, implements: implementationOptions };
@@ -146,6 +176,15 @@ describe('Guard Schematic', () => {
146176
expect(fileString).toContain(expectedImports);
147177
});
148178

179+
it('should add correct imports based on canActivate guardType', async () => {
180+
const options = { ...defaultOptions, guardType: 'canActivate' };
181+
const tree = await schematicRunner.runSchematicAsync('guard', options, appTree).toPromise();
182+
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts');
183+
const expectedImports = `import { CanActivateFn } from '@angular/router';`;
184+
185+
expect(fileString).toContain(expectedImports);
186+
});
187+
149188
it('should add correct imports if multiple implementations was selected', async () => {
150189
const implementationOptions = ['CanActivate', 'CanLoad', 'CanActivateChild'];
151190
const options = { ...defaultOptions, implements: implementationOptions };

‎packages/schematics/angular/guard/schema.json

+6-3
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,12 @@
4949
"items": {
5050
"enum": ["CanActivate", "CanActivateChild", "CanDeactivate", "CanLoad", "CanMatch"],
5151
"type": "string"
52-
},
53-
"default": ["CanActivate"],
54-
"x-prompt": "Which interfaces would you like to implement?"
52+
}
53+
},
54+
"guardType": {
55+
"type": "string",
56+
"description": "Specifies type of guard to generate.",
57+
"enum": ["canActivate", "canActivateChild", "canDeactivate", "canLoad", "canMatch"]
5558
}
5659
},
5760
"required": ["name", "project"]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { EnvironmentInjector } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
3+
import { <%= guardType %> } from '@angular/router';
4+
5+
import { <%= camelize(name) %>Guard } from './<%= dasherize(name) %>.guard';
6+
7+
describe('<%= camelize(name) %>Guard', () => {
8+
const executeGuard: <%= guardType %> = (...guardParameters) =>
9+
TestBed.inject(EnvironmentInjector).runInContext(() => <%= camelize(name) %>Guard(...guardParameters));
10+
11+
beforeEach(() => {
12+
TestBed.configureTestingModule({});
13+
});
14+
15+
it('should be created', () => {
16+
expect(<%= camelize(name) %>Guard).toBeTruthy();
17+
});
18+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { <%= guardType %> } from '@angular/router';
2+
3+
export const <%= camelize(name) %>Guard: <%= guardType %><% if (guardType === 'CanDeactivateFn') { %><unknown><% } %> = <%
4+
if (guardType === 'CanMatchFn' || guardType === 'CanLoadFn') { %>(route, segments)<% }
5+
%><% if (guardType === 'CanActivateFn') { %>(route, state)<% }
6+
%><% if (guardType === 'CanActivateChildFn') { %>(childRoute, state)<% }
7+
%><% if (guardType === 'CanDeactivateFn') { %>(component, currentRoute, currentState, nextState)<% } %> => {
8+
return true;
9+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { ResolveFn } from '@angular/router';
3+
4+
import { <%= camelize(name) %>Resolver } from './<%= dasherize(name) %>.resolver';
5+
6+
describe('<%= camelize(name) %>Resolver', () => {
7+
const executeResolver: ResolveFn<boolean> = (...resolverParameters) =>
8+
TestBed.inject(EnvironmentInjector).runInContext(() => <%= camelize(name) %>Resolver(...resolverParameters));
9+
10+
beforeEach(() => {
11+
TestBed.configureTestingModule({});
12+
});
13+
14+
it('should be created', () => {
15+
expect(resolver).toBeTruthy();
16+
});
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { ResolveFn } from '@angular/router';
2+
3+
export const <%= camelize(name) %>Resolver: ResolveFn<boolean> = (route, state) => {
4+
return true;
5+
}

‎packages/schematics/angular/resolver/index.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,7 @@ import { generateFromFiles } from '../utility/generate-from-files';
1111
import { Schema } from './schema';
1212

1313
export default function (options: Schema): Rule {
14-
return generateFromFiles(options);
14+
return options.functional
15+
? generateFromFiles({ ...options, templateFilesDirectory: './functional-files' })
16+
: generateFromFiles({ ...options, templateFilesDirectory: './class-files' });
1517
}

‎packages/schematics/angular/resolver/index_spec.ts

+23
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,27 @@ describe('resolver Schematic', () => {
7979
.toPromise();
8080
expect(appTree.files).toContain('/projects/bar/custom/app/foo.resolver.ts');
8181
});
82+
83+
it('should create a functional resolver', async () => {
84+
const tree = await schematicRunner
85+
.runSchematicAsync('resolver', { ...defaultOptions, functional: true }, appTree)
86+
.toPromise();
87+
const fileString = tree.readContent('/projects/bar/src/app/foo.resolver.ts');
88+
expect(fileString).toContain(
89+
'export const fooResolver: ResolveFn<boolean> = (route, state) => {',
90+
);
91+
});
92+
93+
it('should create a helper function to run a functional resolver in a test', async () => {
94+
const tree = await schematicRunner
95+
.runSchematicAsync('resolver', { ...defaultOptions, functional: true }, appTree)
96+
.toPromise();
97+
const fileString = tree.readContent('/projects/bar/src/app/foo.resolver.spec.ts');
98+
expect(fileString).toContain(
99+
'const executeResolver: ResolveFn<boolean> = (...resolverParameters) => ',
100+
);
101+
expect(fileString).toContain(
102+
'TestBed.inject(EnvironmentInjector).runInContext(() => fooResolver(...resolverParameters));',
103+
);
104+
});
82105
});

‎packages/schematics/angular/resolver/schema.json

+5
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@
2525
"description": "When true (the default), creates the new files at the top level of the current project.",
2626
"default": true
2727
},
28+
"functional": {
29+
"type": "boolean",
30+
"description": "Creates the resolver as a `ResolveFn`.",
31+
"default": false
32+
},
2833
"path": {
2934
"type": "string",
3035
"format": "path",

‎packages/schematics/angular/utility/generate-from-files.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface GenerateFromFilesOptions {
3030
prefix?: string;
3131
project: string;
3232
skipTests?: boolean;
33+
templateFilesDirectory?: string;
3334
}
3435

3536
export function generateFromFiles(
@@ -47,7 +48,8 @@ export function generateFromFiles(
4748

4849
validateClassName(strings.classify(options.name));
4950

50-
const templateSource = apply(url('./files'), [
51+
const templateFilesDirectory = options.templateFilesDirectory ?? './files';
52+
const templateSource = apply(url(templateFilesDirectory), [
5153
options.skipTests ? filter((path) => !path.endsWith('.spec.ts.template')) : noop(),
5254
applyTemplates({
5355
...strings,

0 commit comments

Comments
 (0)
Please sign in to comment.