Skip to content

Commit

Permalink
feat(@schematics/angular): Add schematics for generating functional r…
Browse files Browse the repository at this point in the history
…outer 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.
  • Loading branch information
atscott authored and alan-agius4 committed Nov 9, 2022
1 parent 827fecc commit 6c39a16
Show file tree
Hide file tree
Showing 16 changed files with 186 additions and 40 deletions.
8 changes: 8 additions & 0 deletions packages/schematics/angular/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ ts_library(
# Also exclude templated files.
"*/files/**/*.ts",
"*/other-files/**/*.ts",
"*/implements-files/**/*",
"*/type-files/**/*",
"*/functional-files/**/*",
"*/class-files/**/*",
# Exclude test helpers.
"utility/test/**/*.ts",
# NB: we need to exclude the nested node_modules that is laid out by yarn workspaces
Expand All @@ -65,6 +69,10 @@ ts_library(
"*/schema.json",
"*/files/**/*",
"*/other-files/**/*",
"*/implements-files/**/*",
"*/type-files/**/*",
"*/functional-files/**/*",
"*/class-files/**/*",
],
exclude = [
# NB: we need to exclude the nested node_modules that is laid out by yarn workspaces
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { <%= implementationImports %> } from '@angular/router';
import { <%= routerImports %> } from '@angular/router';
import { Observable } from 'rxjs';

@Injectable({
Expand Down
59 changes: 37 additions & 22 deletions packages/schematics/angular/guard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,54 @@
*/

import { Rule, SchematicsException } from '@angular-devkit/schematics';

import { generateFromFiles } from '../utility/generate-from-files';

import { Implement as GuardInterface, Schema as GuardOptions } from './schema';

export default function (options: GuardOptions): Rule {
if (!options.implements) {
throw new SchematicsException('Option "implements" is required.');
if (options.implements && options.implements.length > 0 && options.guardType) {
throw new SchematicsException('Options "implements" and "guardType" cannot be used together.');
}

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

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

if (options.implements.length > 1) {
const implementations = options.implements
.map((implement) => (implement === 'CanDeactivate' ? 'CanDeactivate<unknown>' : implement))
.join(', ');
const commonRouterNameImports = ['ActivatedRouteSnapshot', 'RouterStateSnapshot'];
const routerNamedImports: string[] = [...options.implements, 'UrlTree'];

if (
options.implements.includes(GuardInterface.CanLoad) ||
options.implements.includes(GuardInterface.CanMatch)
) {
routerNamedImports.push('Route', 'UrlSegment');

if (options.implements.length > 1) {
routerNamedImports.push(...commonRouterNameImports);
}
} else {
routerNamedImports.push(...commonRouterNameImports);
}
} else {
routerNamedImports.push(...commonRouterNameImports);
}

routerNamedImports.sort();
routerNamedImports.sort();

const implementationImports = routerNamedImports.join(', ');
const routerImports = routerNamedImports.join(', ');

return generateFromFiles(options, {
implementations,
implementationImports,
});
return generateFromFiles(
{ ...options, templateFilesDirectory: './implements-files' },
{
implementations,
routerImports,
},
);
}
}
63 changes: 51 additions & 12 deletions packages/schematics/angular/guard/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
*/

import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing';

import { Schema as ApplicationOptions } from '../application/schema';
import { Schema as WorkspaceOptions } from '../workspace/schema';

import { Schema as GuardOptions } from './schema';

describe('Guard Schematic', () => {
Expand Down Expand Up @@ -90,6 +92,37 @@ describe('Guard Schematic', () => {
expect(fileString).not.toContain('canLoad');
});

it('should respect the guardType value', async () => {
const options = { ...defaultOptions, guardType: 'canActivate' };
const tree = await schematicRunner.runSchematicAsync('guard', options, appTree).toPromise();
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts');
expect(fileString).toContain('export const fooGuard: CanActivateFn = (route, state) => {');
expect(fileString).not.toContain('CanActivateChild');
expect(fileString).not.toContain('canActivateChild');
expect(fileString).not.toContain('CanLoad');
expect(fileString).not.toContain('canLoad');
});

it('should generate a helper function to execute the guard in a test', async () => {
const options = { ...defaultOptions, guardType: 'canActivate' };
const tree = await schematicRunner.runSchematicAsync('guard', options, appTree).toPromise();
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.spec.ts');
expect(fileString).toContain('const executeGuard: CanActivateFn = (...guardParameters) => ');
expect(fileString).toContain(
'TestBed.inject(EnvironmentInjector).runInContext(() => fooGuard(...guardParameters));',
);
});

it('should generate CanDeactivateFn with unknown guardType', async () => {
const options = { ...defaultOptions, guardType: 'canDeactivate' };
const tree = await schematicRunner.runSchematicAsync('guard', options, appTree).toPromise();
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts');
expect(fileString).toContain(
'export const fooGuard: CanDeactivateFn<unknown> = ' +
'(component, currentRoute, currentState, nextState) => {',
);
});

it('should respect the implements values', async () => {
const implementationOptions = ['CanActivate', 'CanLoad', 'CanActivateChild'];
const options = { ...defaultOptions, implements: implementationOptions };
Expand All @@ -104,18 +137,6 @@ describe('Guard Schematic', () => {
});
});

it('should use CanActivate if no implements value', async () => {
const options = { ...defaultOptions, implements: undefined };
const tree = await schematicRunner.runSchematicAsync('guard', options, appTree).toPromise();
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts');
expect(fileString).toContain('CanActivate');
expect(fileString).toContain('canActivate');
expect(fileString).not.toContain('CanActivateChild');
expect(fileString).not.toContain('canActivateChild');
expect(fileString).not.toContain('CanLoad');
expect(fileString).not.toContain('canLoad');
});

it('should add correct imports based on CanLoad implementation', async () => {
const implementationOptions = ['CanLoad'];
const options = { ...defaultOptions, implements: implementationOptions };
Expand All @@ -136,6 +157,15 @@ describe('Guard Schematic', () => {
expect(fileString).toContain(expectedImports);
});

it('should add correct imports based on canLoad guardType', async () => {
const options = { ...defaultOptions, guardType: 'canLoad' };
const tree = await schematicRunner.runSchematicAsync('guard', options, appTree).toPromise();
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts');
const expectedImports = `import { CanLoadFn } from '@angular/router';`;

expect(fileString).toContain(expectedImports);
});

it('should add correct imports based on CanActivate implementation', async () => {
const implementationOptions = ['CanActivate'];
const options = { ...defaultOptions, implements: implementationOptions };
Expand All @@ -146,6 +176,15 @@ describe('Guard Schematic', () => {
expect(fileString).toContain(expectedImports);
});

it('should add correct imports based on canActivate guardType', async () => {
const options = { ...defaultOptions, guardType: 'canActivate' };
const tree = await schematicRunner.runSchematicAsync('guard', options, appTree).toPromise();
const fileString = tree.readContent('/projects/bar/src/app/foo.guard.ts');
const expectedImports = `import { CanActivateFn } from '@angular/router';`;

expect(fileString).toContain(expectedImports);
});

it('should add correct imports if multiple implementations was selected', async () => {
const implementationOptions = ['CanActivate', 'CanLoad', 'CanActivateChild'];
const options = { ...defaultOptions, implements: implementationOptions };
Expand Down
9 changes: 6 additions & 3 deletions packages/schematics/angular/guard/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,12 @@
"items": {
"enum": ["CanActivate", "CanActivateChild", "CanDeactivate", "CanLoad", "CanMatch"],
"type": "string"
},
"default": ["CanActivate"],
"x-prompt": "Which interfaces would you like to implement?"
}
},
"guardType": {
"type": "string",
"description": "Specifies type of guard to generate.",
"enum": ["canActivate", "canActivateChild", "canDeactivate", "canLoad", "canMatch"]
}
},
"required": ["name", "project"]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { EnvironmentInjector } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { <%= guardType %> } from '@angular/router';

import { <%= camelize(name) %>Guard } from './<%= dasherize(name) %>.guard';

describe('<%= camelize(name) %>Guard', () => {
const executeGuard: <%= guardType %> = (...guardParameters) =>
TestBed.inject(EnvironmentInjector).runInContext(() => <%= camelize(name) %>Guard(...guardParameters));

beforeEach(() => {
TestBed.configureTestingModule({});
});

it('should be created', () => {
expect(<%= camelize(name) %>Guard).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { <%= guardType %> } from '@angular/router';

export const <%= camelize(name) %>Guard: <%= guardType %><% if (guardType === 'CanDeactivateFn') { %><unknown><% } %> = <%
if (guardType === 'CanMatchFn' || guardType === 'CanLoadFn') { %>(route, segments)<% }
%><% if (guardType === 'CanActivateFn') { %>(route, state)<% }
%><% if (guardType === 'CanActivateChildFn') { %>(childRoute, state)<% }
%><% if (guardType === 'CanDeactivateFn') { %>(component, currentRoute, currentState, nextState)<% } %> => {
return true;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { TestBed } from '@angular/core/testing';
import { ResolveFn } from '@angular/router';

import { <%= camelize(name) %>Resolver } from './<%= dasherize(name) %>.resolver';

describe('<%= camelize(name) %>Resolver', () => {
const executeResolver: ResolveFn<boolean> = (...resolverParameters) =>
TestBed.inject(EnvironmentInjector).runInContext(() => <%= camelize(name) %>Resolver(...resolverParameters));

beforeEach(() => {
TestBed.configureTestingModule({});
});

it('should be created', () => {
expect(resolver).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ResolveFn } from '@angular/router';

export const <%= camelize(name) %>Resolver: ResolveFn<boolean> = (route, state) => {
return true;
}
4 changes: 3 additions & 1 deletion packages/schematics/angular/resolver/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ import { generateFromFiles } from '../utility/generate-from-files';
import { Schema } from './schema';

export default function (options: Schema): Rule {
return generateFromFiles(options);
return options.functional
? generateFromFiles({ ...options, templateFilesDirectory: './functional-files' })
: generateFromFiles({ ...options, templateFilesDirectory: './class-files' });
}
23 changes: 23 additions & 0 deletions packages/schematics/angular/resolver/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,27 @@ describe('resolver Schematic', () => {
.toPromise();
expect(appTree.files).toContain('/projects/bar/custom/app/foo.resolver.ts');
});

it('should create a functional resolver', async () => {
const tree = await schematicRunner
.runSchematicAsync('resolver', { ...defaultOptions, functional: true }, appTree)
.toPromise();
const fileString = tree.readContent('/projects/bar/src/app/foo.resolver.ts');
expect(fileString).toContain(
'export const fooResolver: ResolveFn<boolean> = (route, state) => {',
);
});

it('should create a helper function to run a functional resolver in a test', async () => {
const tree = await schematicRunner
.runSchematicAsync('resolver', { ...defaultOptions, functional: true }, appTree)
.toPromise();
const fileString = tree.readContent('/projects/bar/src/app/foo.resolver.spec.ts');
expect(fileString).toContain(
'const executeResolver: ResolveFn<boolean> = (...resolverParameters) => ',
);
expect(fileString).toContain(
'TestBed.inject(EnvironmentInjector).runInContext(() => fooResolver(...resolverParameters));',
);
});
});
5 changes: 5 additions & 0 deletions packages/schematics/angular/resolver/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
"description": "When true (the default), creates the new files at the top level of the current project.",
"default": true
},
"functional": {
"type": "boolean",
"description": "Creates the resolver as a `ResolveFn`.",
"default": false
},
"path": {
"type": "string",
"format": "path",
Expand Down
4 changes: 3 additions & 1 deletion packages/schematics/angular/utility/generate-from-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface GenerateFromFilesOptions {
prefix?: string;
project: string;
skipTests?: boolean;
templateFilesDirectory?: string;
}

export function generateFromFiles(
Expand All @@ -47,7 +48,8 @@ export function generateFromFiles(

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

const templateSource = apply(url('./files'), [
const templateFilesDirectory = options.templateFilesDirectory ?? './files';
const templateSource = apply(url(templateFilesDirectory), [
options.skipTests ? filter((path) => !path.endsWith('.spec.ts.template')) : noop(),
applyTemplates({
...strings,
Expand Down

0 comments on commit 6c39a16

Please sign in to comment.