From 112324a614df3bd33ead86d2305b5545e5e226f0 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Mon, 16 Nov 2020 15:41:39 -0800 Subject: [PATCH] feat(router): add `relativeTo` as an input to `routerLink` (#39720) Allow configuration of `relativeTo` in the `routerLink` directive. This is related to the clearing of auxiliary routes, where you need to use `relativeTo: route.parent` in order to clear it from the activated auxiliary component itself. This is because `relativeTo: route` will consume the segment that we're trying to clear, so there is really no way to do this with routerLink at the moment. Related issue: #13523 Related (internal link): https://yaqs.corp.google.com/eng/q/5999443644645376 PR Close #39720 --- goldens/public-api/router/router.d.ts | 2 + packages/router/src/directives/router_link.ts | 30 ++++++++++- packages/router/src/router.ts | 6 +++ packages/router/test/integration.spec.ts | 52 ++++++++++++++++++- 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/goldens/public-api/router/router.d.ts b/goldens/public-api/router/router.d.ts index 73cb56deb0..bb9bc0a39f 100644 --- a/goldens/public-api/router/router.d.ts +++ b/goldens/public-api/router/router.d.ts @@ -380,6 +380,7 @@ export declare class RouterLink implements OnChanges { preserveFragment: boolean; queryParams?: Params | null; queryParamsHandling?: QueryParamsHandling | null; + relativeTo?: ActivatedRoute | null; replaceUrl: boolean; set routerLink(commands: any[] | string | null | undefined); skipLocationChange: boolean; @@ -412,6 +413,7 @@ export declare class RouterLinkWithHref implements OnChanges, OnDestroy { preserveFragment: boolean; queryParams?: Params | null; queryParamsHandling?: QueryParamsHandling | null; + relativeTo?: ActivatedRoute | null; replaceUrl: boolean; set routerLink(commands: any[] | string | null | undefined); skipLocationChange: boolean; diff --git a/packages/router/src/directives/router_link.ts b/packages/router/src/directives/router_link.ts index 01f782b6af..64acd67024 100644 --- a/packages/router/src/directives/router_link.ts +++ b/packages/router/src/directives/router_link.ts @@ -169,6 +169,17 @@ export class RouterLink implements OnChanges { * @see {@link Router#navigateByUrl Router#navigateByUrl} */ @Input() state?: {[k: string]: any}; + /** + * Passed to {@link Router#createUrlTree Router#createUrlTree} as part of the + * `UrlCreationOptions`. + * Specify a value here when you do not want to use the default value + * for `routerLink`, which is the current activated route. + * Note that a value of `undefined` here will use the `routerLink` default. + * @see {@link UrlCreationOptions#relativeTo UrlCreationOptions#relativeTo} + * @see {@link Router#createUrlTree Router#createUrlTree} + */ + @Input() relativeTo?: ActivatedRoute|null; + private commands: any[] = []; private preserve!: boolean; @@ -220,7 +231,9 @@ export class RouterLink implements OnChanges { get urlTree(): UrlTree { return this.router.createUrlTree(this.commands, { - relativeTo: this.route, + // If the `relativeTo` input is not defined, we want to use `this.route` by default. + // Otherwise, we should use the value provided by the user in the input. + relativeTo: this.relativeTo !== undefined ? this.relativeTo : this.route, queryParams: this.queryParams, fragment: this.fragment, queryParamsHandling: this.queryParamsHandling, @@ -296,6 +309,17 @@ export class RouterLinkWithHref implements OnChanges, OnDestroy { * @see {@link Router#navigateByUrl Router#navigateByUrl} */ @Input() state?: {[k: string]: any}; + /** + * Passed to {@link Router#createUrlTree Router#createUrlTree} as part of the + * `UrlCreationOptions`. + * Specify a value here when you do not want to use the default value + * for `routerLink`, which is the current activated route. + * Note that a value of `undefined` here will use the `routerLink` default. + * @see {@link UrlCreationOptions#relativeTo UrlCreationOptions#relativeTo} + * @see {@link Router#createUrlTree Router#createUrlTree} + */ + @Input() relativeTo?: ActivatedRoute|null; + private commands: any[] = []; private subscription: Subscription; // TODO(issue/24571): remove '!'. @@ -373,7 +397,9 @@ export class RouterLinkWithHref implements OnChanges, OnDestroy { get urlTree(): UrlTree { return this.router.createUrlTree(this.commands, { - relativeTo: this.route, + // If the `relativeTo` input is not defined, we want to use `this.route` by default. + // Otherwise, we should use the value provided by the user in the input. + relativeTo: this.relativeTo !== undefined ? this.relativeTo : this.route, queryParams: this.queryParams, fragment: this.fragment, queryParamsHandling: this.queryParamsHandling, diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index f560d20dcf..0e5b4b7679 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -81,6 +81,9 @@ export interface UrlCreationOptions { * } * } * ``` + * + * A value of `null` or `undefined` indicates that the navigation commands should be applied + * relative to the root. */ relativeTo?: ActivatedRoute|null; @@ -1094,6 +1097,9 @@ export class Router { * * // navigate to /team/44/user/22 * router.createUrlTree(['../../team/44/user/22'], {relativeTo: route}); + * + * Note that a value of `null` or `undefined` for `relativeTo` indicates that the + * tree should be created relative to the root. * ``` */ createUrlTree(commands: any[], navigationExtras: UrlCreationOptions = {}): UrlTree { diff --git a/packages/router/test/integration.spec.ts b/packages/router/test/integration.spec.ts index e7f6c7bae1..cae9479ef0 100644 --- a/packages/router/test/integration.spec.ts +++ b/packages/router/test/integration.spec.ts @@ -8,11 +8,12 @@ import {CommonModule, Location} from '@angular/common'; import {SpyLocation} from '@angular/common/testing'; -import {ChangeDetectionStrategy, Component, Injectable, NgModule, NgModuleFactoryLoader, NgModuleRef, NgZone, OnDestroy, ɵConsole as Console, ɵNoopNgZone as NoopNgZone} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Injectable, NgModule, NgModuleFactoryLoader, NgModuleRef, NgZone, OnDestroy, ViewChild, ɵConsole as Console, ɵNoopNgZone as NoopNgZone} from '@angular/core'; import {ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing'; +import {describe} from '@angular/core/testing/src/testing_internal'; import {By} from '@angular/platform-browser/src/dom/debug/by'; import {expect} from '@angular/platform-browser/testing/src/matchers'; -import {ActivatedRoute, ActivatedRouteSnapshot, ActivationEnd, ActivationStart, CanActivate, CanDeactivate, ChildActivationEnd, ChildActivationStart, DefaultUrlSerializer, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, Navigation, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ParamMap, Params, PreloadAllModules, PreloadingStrategy, PRIMARY_OUTLET, Resolve, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouteReuseStrategy, RouterEvent, RouterModule, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegmentGroup, UrlSerializer, UrlTree} from '@angular/router'; +import {ActivatedRoute, ActivatedRouteSnapshot, ActivationEnd, ActivationStart, CanActivate, CanDeactivate, ChildActivationEnd, ChildActivationStart, DefaultUrlSerializer, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, Navigation, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ParamMap, Params, PreloadAllModules, PreloadingStrategy, PRIMARY_OUTLET, Resolve, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouteReuseStrategy, RouterEvent, RouterLink, RouterLinkWithHref, RouterModule, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegmentGroup, UrlSerializer, UrlTree} from '@angular/router'; import {EMPTY, Observable, Observer, of, Subscription} from 'rxjs'; import {delay, filter, first, map, mapTo, tap} from 'rxjs/operators'; @@ -5382,6 +5383,53 @@ describe('Integration', () => { }))); }); + it('can use `relativeTo` `route.parent` in `routerLink` to close secondary outlet', + fakeAsync(() => { + // Given + @Component({template: ''}) + class ChildRootCmp { + } + + @Component({ + selector: 'link-cmp', + template: + `link + + ` + }) + class RelativeLinkCmp { + @ViewChild(RouterLink) buttonLink!: RouterLink; + @ViewChild(RouterLinkWithHref) aLink!: RouterLink; + + constructor(readonly route: ActivatedRoute) {} + } + @NgModule({ + declarations: [RelativeLinkCmp, ChildRootCmp], + imports: [RouterModule.forChild([{ + path: 'childRoot', + component: ChildRootCmp, + children: [ + {path: 'popup', outlet: 'secondary', component: RelativeLinkCmp}, + ] + }])] + }) + class LazyLoadedModule { + } + const router = TestBed.inject(Router); + router.resetConfig([{path: 'root', loadChildren: () => LazyLoadedModule}]); + + // When + router.navigateByUrl('/root/childRoot/(secondary:popup)'); + const fixture = createRoot(router, RootCmp); + advance(fixture); + + // Then + const relativeLinkCmp = + fixture.debugElement.query(By.directive(RelativeLinkCmp)).componentInstance; + expect(relativeLinkCmp.aLink.urlTree.toString()).toEqual('/root/childRoot'); + expect(relativeLinkCmp.buttonLink.urlTree.toString()).toEqual('/root/childRoot'); + })); + describe('relativeLinkResolution', () => { @Component({selector: 'link-cmp', template: `link`}) class RelativeLinkCmp {