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..a0fdc3c3e18d 100644 --- a/packages/schematics/angular/guard/index.ts +++ b/packages/schematics/angular/guard/index.ts @@ -7,39 +7,55 @@ */ 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.length > 1 && options.functional) { + throw new SchematicsException( + 'Can only specify one value for implements when generating a functional guard.', + ); + } - 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.functional) { + const guardType = options.implements[0] + 'Fn'; - if (options.implements.length > 1) { + return generateFromFiles({ ...options, templateFilesDirectory: './type-files' }, { guardType }); + } else { + 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..ebf85dd23529 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 functional guard value', async () => { + const options = { ...defaultOptions, implements: ['CanActivate'], functional: true }; + 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, implements: ['CanActivate'], functional: true }; + 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 functional guard', async () => { + const options = { ...defaultOptions, implements: ['CanDeactivate'], functional: true }; + 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 functional guard', async () => { + const options = { ...defaultOptions, implements: ['CanLoad'], functional: true }; + 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 functional guard', async () => { + const options = { ...defaultOptions, implements: ['CanActivate'], functional: true }; + 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..d2df59cb0446 100644 --- a/packages/schematics/angular/guard/schema.json +++ b/packages/schematics/angular/guard/schema.json @@ -41,9 +41,14 @@ "$source": "projectName" } }, + "functional": { + "type": "boolean", + "description": "Specifies whether to generate a guard as a function.", + "default": false + }, "implements": { "type": "array", - "description": "Specifies which interfaces to implement.", + "description": "Specifies which type of guard to create.", "uniqueItems": true, "minItems": 1, "items": { @@ -51,7 +56,7 @@ "type": "string" }, "default": ["CanActivate"], - "x-prompt": "Which interfaces would you like to implement?" + "x-prompt": "Which type of guard would you like to create?" } }, "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,