From 6c39a162bec67083bf6c11b54e84612f1d68c384 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Tue, 1 Nov 2022 13:19:42 -0700 Subject: [PATCH] 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. --- packages/schematics/angular/BUILD.bazel | 8 +++ .../__name@dasherize__.guard.spec.ts.template | 0 .../__name@dasherize__.guard.ts.template | 2 +- packages/schematics/angular/guard/index.ts | 59 ++++++++++------- .../schematics/angular/guard/index_spec.ts | 63 +++++++++++++++---- packages/schematics/angular/guard/schema.json | 9 ++- .../__name@dasherize__.guard.spec.ts.template | 18 ++++++ .../__name@dasherize__.guard.ts.template | 9 +++ ...name@dasherize__.resolver.spec.ts.template | 0 .../__name@dasherize__.resolver.ts.template | 0 ...name@dasherize__.resolver.spec.ts.template | 17 +++++ .../__name@dasherize__.resolver.ts.template | 5 ++ packages/schematics/angular/resolver/index.ts | 4 +- .../schematics/angular/resolver/index_spec.ts | 23 +++++++ .../schematics/angular/resolver/schema.json | 5 ++ .../angular/utility/generate-from-files.ts | 4 +- 16 files changed, 186 insertions(+), 40 deletions(-) rename packages/schematics/angular/guard/{files => implements-files}/__name@dasherize__.guard.spec.ts.template (100%) rename packages/schematics/angular/guard/{files => implements-files}/__name@dasherize__.guard.ts.template (95%) create mode 100644 packages/schematics/angular/guard/type-files/__name@dasherize__.guard.spec.ts.template create mode 100644 packages/schematics/angular/guard/type-files/__name@dasherize__.guard.ts.template rename packages/schematics/angular/resolver/{files => class-files}/__name@dasherize__.resolver.spec.ts.template (100%) rename packages/schematics/angular/resolver/{files => class-files}/__name@dasherize__.resolver.ts.template (100%) create mode 100644 packages/schematics/angular/resolver/functional-files/__name@dasherize__.resolver.spec.ts.template create mode 100644 packages/schematics/angular/resolver/functional-files/__name@dasherize__.resolver.ts.template diff --git a/packages/schematics/angular/BUILD.bazel b/packages/schematics/angular/BUILD.bazel index 8210ad2207ab..83da30a8bacc 100644 --- a/packages/schematics/angular/BUILD.bazel +++ b/packages/schematics/angular/BUILD.bazel @@ -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 @@ -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 diff --git a/packages/schematics/angular/guard/files/__name@dasherize__.guard.spec.ts.template b/packages/schematics/angular/guard/implements-files/__name@dasherize__.guard.spec.ts.template similarity index 100% rename from packages/schematics/angular/guard/files/__name@dasherize__.guard.spec.ts.template rename to packages/schematics/angular/guard/implements-files/__name@dasherize__.guard.spec.ts.template diff --git a/packages/schematics/angular/guard/files/__name@dasherize__.guard.ts.template b/packages/schematics/angular/guard/implements-files/__name@dasherize__.guard.ts.template similarity index 95% rename from packages/schematics/angular/guard/files/__name@dasherize__.guard.ts.template rename to packages/schematics/angular/guard/implements-files/__name@dasherize__.guard.ts.template index 8d83bc7498b4..5f9dac95d0b4 100644 --- a/packages/schematics/angular/guard/files/__name@dasherize__.guard.ts.template +++ b/packages/schematics/angular/guard/implements-files/__name@dasherize__.guard.ts.template @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { <%= implementationImports %> } from '@angular/router'; +import { <%= routerImports %> } from '@angular/router'; import { Observable } from 'rxjs'; @Injectable({ diff --git a/packages/schematics/angular/guard/index.ts b/packages/schematics/angular/guard/index.ts index efb377216684..f5b4e5fea664 100644 --- a/packages/schematics/angular/guard/index.ts +++ b/packages/schematics/angular/guard/index.ts @@ -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' : 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' : 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, + }, + ); + } } diff --git a/packages/schematics/angular/guard/index_spec.ts b/packages/schematics/angular/guard/index_spec.ts index 45326eba1862..fdb136701891 100644 --- a/packages/schematics/angular/guard/index_spec.ts +++ b/packages/schematics/angular/guard/index_spec.ts @@ -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', () => { @@ -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 = ' + + '(component, currentRoute, currentState, nextState) => {', + ); + }); + it('should respect the implements values', async () => { const implementationOptions = ['CanActivate', 'CanLoad', 'CanActivateChild']; const options = { ...defaultOptions, implements: implementationOptions }; @@ -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 }; @@ -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 }; @@ -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 }; diff --git a/packages/schematics/angular/guard/schema.json b/packages/schematics/angular/guard/schema.json index d49d12778803..f25a55f17437 100644 --- a/packages/schematics/angular/guard/schema.json +++ b/packages/schematics/angular/guard/schema.json @@ -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"] diff --git a/packages/schematics/angular/guard/type-files/__name@dasherize__.guard.spec.ts.template b/packages/schematics/angular/guard/type-files/__name@dasherize__.guard.spec.ts.template new file mode 100644 index 000000000000..4cbcd9b606d6 --- /dev/null +++ b/packages/schematics/angular/guard/type-files/__name@dasherize__.guard.spec.ts.template @@ -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(); + }); +}); diff --git a/packages/schematics/angular/guard/type-files/__name@dasherize__.guard.ts.template b/packages/schematics/angular/guard/type-files/__name@dasherize__.guard.ts.template new file mode 100644 index 000000000000..9f82b7681ecd --- /dev/null +++ b/packages/schematics/angular/guard/type-files/__name@dasherize__.guard.ts.template @@ -0,0 +1,9 @@ +import { <%= guardType %> } from '@angular/router'; + +export const <%= camelize(name) %>Guard: <%= guardType %><% if (guardType === 'CanDeactivateFn') { %><% } %> = <% + 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; +} diff --git a/packages/schematics/angular/resolver/files/__name@dasherize__.resolver.spec.ts.template b/packages/schematics/angular/resolver/class-files/__name@dasherize__.resolver.spec.ts.template similarity index 100% rename from packages/schematics/angular/resolver/files/__name@dasherize__.resolver.spec.ts.template rename to packages/schematics/angular/resolver/class-files/__name@dasherize__.resolver.spec.ts.template diff --git a/packages/schematics/angular/resolver/files/__name@dasherize__.resolver.ts.template b/packages/schematics/angular/resolver/class-files/__name@dasherize__.resolver.ts.template similarity index 100% rename from packages/schematics/angular/resolver/files/__name@dasherize__.resolver.ts.template rename to packages/schematics/angular/resolver/class-files/__name@dasherize__.resolver.ts.template diff --git a/packages/schematics/angular/resolver/functional-files/__name@dasherize__.resolver.spec.ts.template b/packages/schematics/angular/resolver/functional-files/__name@dasherize__.resolver.spec.ts.template new file mode 100644 index 000000000000..9b521b298d4d --- /dev/null +++ b/packages/schematics/angular/resolver/functional-files/__name@dasherize__.resolver.spec.ts.template @@ -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 = (...resolverParameters) => + TestBed.inject(EnvironmentInjector).runInContext(() => <%= camelize(name) %>Resolver(...resolverParameters)); + + beforeEach(() => { + TestBed.configureTestingModule({}); + }); + + it('should be created', () => { + expect(resolver).toBeTruthy(); + }); +}); diff --git a/packages/schematics/angular/resolver/functional-files/__name@dasherize__.resolver.ts.template b/packages/schematics/angular/resolver/functional-files/__name@dasherize__.resolver.ts.template new file mode 100644 index 000000000000..64ceec5c4272 --- /dev/null +++ b/packages/schematics/angular/resolver/functional-files/__name@dasherize__.resolver.ts.template @@ -0,0 +1,5 @@ +import { ResolveFn } from '@angular/router'; + +export const <%= camelize(name) %>Resolver: ResolveFn = (route, state) => { + return true; +} diff --git a/packages/schematics/angular/resolver/index.ts b/packages/schematics/angular/resolver/index.ts index 35ae7dd27503..2a08b956cae8 100644 --- a/packages/schematics/angular/resolver/index.ts +++ b/packages/schematics/angular/resolver/index.ts @@ -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' }); } diff --git a/packages/schematics/angular/resolver/index_spec.ts b/packages/schematics/angular/resolver/index_spec.ts index af2fdd22087e..95f1bdde8305 100644 --- a/packages/schematics/angular/resolver/index_spec.ts +++ b/packages/schematics/angular/resolver/index_spec.ts @@ -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 = (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 = (...resolverParameters) => ', + ); + expect(fileString).toContain( + 'TestBed.inject(EnvironmentInjector).runInContext(() => fooResolver(...resolverParameters));', + ); + }); }); diff --git a/packages/schematics/angular/resolver/schema.json b/packages/schematics/angular/resolver/schema.json index 72b5620630c1..f6f3d97987ee 100644 --- a/packages/schematics/angular/resolver/schema.json +++ b/packages/schematics/angular/resolver/schema.json @@ -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", diff --git a/packages/schematics/angular/utility/generate-from-files.ts b/packages/schematics/angular/utility/generate-from-files.ts index eaf8febb54e5..a4e34b6bd188 100644 --- a/packages/schematics/angular/utility/generate-from-files.ts +++ b/packages/schematics/angular/utility/generate-from-files.ts @@ -30,6 +30,7 @@ export interface GenerateFromFilesOptions { prefix?: string; project: string; skipTests?: boolean; + templateFilesDirectory?: string; } export function generateFromFiles( @@ -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,