Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with: Claude Englebert
Ben Nadel at Scotch On The Rock (SOTR) 2010 (London) with: Claude Englebert@cfemea )

Using A Wild Card Route (**) To Traverse Arbitrarily Nested Data In Angular 7.2.4

By Ben Nadel on

One of the things that I truly love about the Angular Router is that it enables us, as application developers, to move more and more application state into the URL (thank you so much for Auxiliary Routes). This allows us to make more of the application interface directly addressable via the URL; which, ultimately, makes each Angular application more usable and shareable across the team. Most of the time, when I define routes in the Angular Router, I'm mapping static URL-segments onto Angular Components. But, with the ability to define wild card routes (**), we can use the URL to address arbitrarily nested data. This doesn't come up often; but, I wanted to demonstrate this ability in Angular 7.2.4.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

To set the context for this exploration, think about your computer's Finder (or Window's Explorer). As you drill down into your file system, you're not rendering a bunch of nested interface components - you're using one interface component that updates the list of folders and files based on your current location within the computer's tree of file nodes.

So, for example, when you walk down through the following folder tree:

  • /Users
  • /Users/Ben
  • /Users/Ben/Documents
  • /Users/Ben/Documents/memes
  • /Users/Ben/Documents/memes/With Cats
  • /Users/Ben/Documents/memes/With Cats/cat-math-jump-fail.gif

... each step of the traversal isn't a new interface component, it's the same interface component that is updating its state based on the current traversal path.

Now, let's take that same thought and move it over to an Angular application. In an Angular app, we can think of this Finder path as being akin to our Router URL. When defining our Router path configuration, normally we would use hard-coded segments that map to specific Angular components. But, in this case, we don't know what the segments are going to be ahead of time. And, in fact, the set of valid segments may change dynamically over the life-time of the application.

Luckily, Angular allows us to use a wild card notation - "**" - as a "catch all" in our Router configuration. We can use a "**" wild card in the root of our Router tree to handle "missing routes". But, we can also scope a "**" wild card route to a sub-tree of our Router hierarchy.

This sub-tree wild card allows us to map arbitrary URLs onto a specific Angular component, where we can then inspect the URL in the given component's ActivatedRoute instance. This allows us to move both location and traversal information into the Route itself.

To explore this idea, I'm going to define a component that renders a list of movies. However, the list isn't a flat list - it's a hierarchical list where movies can be organized into categories which can, themselves, be organized into categories. And, our location within this hierarchy will be driven by the Angular Router.

For lack of a better name, I've picked a segment called "/go" as the ingress into our movie component. Everything after "go" in the URL will represent traversal instructions into our hierarchical data. So, for example, we might have URLs that look like this:

  • /go/comedies/tom-hanks/80s/
  • /go/top-10/dramas/
  • /go/sci-fi/so-bad-its-good/space/aliens/cross-species-breeding

To account for this, we're going to define a componentless "go" path that has a locally-scoped "**" wild card route. Here's our Application module:

  • // Import the core angular services.
  • import { BrowserModule } from "@angular/platform-browser";
  • import { NgModule } from "@angular/core";
  • import { RouterModule } from "@angular/router";
  •  
  • // Import the application components and services.
  • import { AppComponent } from "./app.component";
  • import { GoTreeComponent } from "./go-tree.component";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • @NgModule({
  • imports: [
  • BrowserModule,
  • RouterModule.forRoot(
  • [
  • {
  • path: "go",
  • children: [
  • // We're doing to use a "sink" route to capture an arbitrary
  • // path after the "go" segment. This path will represent the
  • // user's point-of-traversal into the hierarchical data. The
  • // segments in this arbitrary path will be accessible via the
  • // "url" portion of the ActivatedRoute.
  • {
  • path: "**",
  • component: GoTreeComponent
  • }
  • ]
  • }
  • ],
  • {
  • // Tell the router to use the hash instead of HTML5 pushstate.
  • useHash: true,
  •  
  • // Enable the Angular 6+ router features for scrolling and anchors.
  • scrollPositionRestoration: "enabled",
  • anchorScrolling: "enabled",
  • enableTracing: false
  • }
  • )
  • ],
  • providers: [
  • // CAUTION: We don't need to specify the LocationStrategy because we are setting
  • // the "useHash" property in the Router module above (which will be setting the
  • // strategy for us).
  • // --
  • // {
  • // provide: LocationStrategy,
  • // useClass: HashLocationStrategy
  • // }
  • ],
  • declarations: [
  • AppComponent,
  • GoTreeComponent
  • ],
  • bootstrap: [
  • AppComponent
  • ]
  • })
  • export class AppModule {
  • // ...
  • }

As you can see, we're going to render the GoTreeComponent Angular component for any and all routes that start with the "/go" prefix. This will allow us to capture and inspect URLs of arbitrary length and use the URL segments to dynamically update our GoTreeComponent's view-template.

Within our GoTreeComponent, we're going to subscribe to the "url" property of our ActivatedRoute instance. This RxJS observable will emit an array of UrlSegment's, each of which will represent a step in our hierarchical data traversal.

To be clear, the way I am implementing this is not the only way to do this. So, don't get too hung up on my particular set of choices. I am trying to keep the demo as simple as possible. Ultimately, the take-away here is that we're translating an arbitrary Route URL onto the internal state-change of a single Angular component.

And, here's the code for my GoTreeComponent. Ultimately, I am listening for URL changes, grabbing the segments from the arbitrary route, and using each segment as a traversal step when locating a "context item" in the data-tree (via .getContextID()). I then use that context item to figure out which movies and categories to render in the view:

  • // Import the core angular services.
  • import { ActivatedRoute } from "@angular/router";
  • import { Component } from "@angular/core";
  • import { Router } from "@angular/router";
  • import { Subscription } from "rxjs";
  •  
  • // Import the application components and services.
  • import { TreeBuilder } from "./tree-builder";
  • import { TreeItem } from "./tree-builder";
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • // Configure our demo data, which is a hierarchy of movies. To make this demo easier to
  • // consume, the demo data is being wrangled with a Builder that automatically tracks the
  • // parent/child relationships using a ParentID.
  • var treeData = new TreeBuilder()
  • .group( "Movies" )
  • .group( "Action / Adventure" )
  • .item( "Die Hard" )
  • .item( "Mechanic" )
  • .group( "Tom Cruise" )
  • .item( "Jack Reacher" )
  • .item( "Mission Impossible" )
  • .up()
  • .up()
  • .group( "RomCom" )
  • .group( "Meg Ryan" )
  • .group( "With Tom Hanks" )
  • .item( "Sleepless in Seattle ")
  • .item( "You've Got Mail" )
  • .up()
  • .item( "When Harry Met Sally" )
  • .up()
  • .item( "10 Things I Hate About You" )
  • .item( "Keeping the Faith" )
  • .up()
  • .group( "Sci-Fi" )
  • .item( "Prometheus" )
  • .group( "Arnold Schwarzenegger" )
  • .item( "Terminator 2" )
  • .item( "Total Recall" )
  • .item( "Running Man" )
  • .up()
  • .up()
  • .getData()
  • ;
  •  
  • // ----------------------------------------------------------------------------------- //
  • // ----------------------------------------------------------------------------------- //
  •  
  • interface Breadcumb {
  • name: string;
  • path: string;
  • }
  •  
  • // When we provide the navigational elements for the tree of hierarchical data, we will
  • // need to map plain-text name values onto URL-encoded segment values. As such, we'll
  • // translate the collection of TreeItem[] into a collection of TreeItemWithPath[], where
  • // "path" is the URL-safe version of the "name".
  • interface TreeItemWithPath extends TreeItem {
  • path: string;
  • }
  •  
  • @Component({
  • selector: "go-tree",
  • styleUrls: [ "./go-tree.component.less" ],
  • template:
  • `
  • <nav class="breadcrumbs">
  • <a [routerLink]="featureRoot">
  • Go
  • </a>
  •  
  • <ng-template ngFor let-breadcrumb [ngForOf]="breadcrumbs">
  •  
  • <span>
  • /
  • </span>
  •  
  • <a [routerLink]="breadcrumb.path">
  • {{ breadcrumb.name}}
  • </a>
  •  
  • </ng-template>
  • </nav>
  •  
  • <ul *ngIf="groups.length" class="groups">
  • <li *ngFor="let group of groups">
  • <a [routerLink]="group.path">
  • {{ group.name }}
  • </a>
  • </li>
  • </ul>
  •  
  • <ul *ngIf="items.length" class="items">
  • <li *ngFor="let item of items">
  • {{ item.name }}
  • </li>
  • </ul>
  • `
  • })
  • export class GoTreeComponent {
  •  
  • public breadcrumbs: Breadcumb[];
  • public featureRoot: string;
  • public groups: TreeItemWithPath[];
  • public items: TreeItemWithPath[];
  •  
  • private activatedRoute: ActivatedRoute;
  • private data: TreeItemWithPath[];
  • private router: Router;
  • private urlSubscription: Subscription | null;
  •  
  • // I initialize the go component.
  • constructor(
  • activatedRoute: ActivatedRoute,
  • router: Router
  • ) {
  •  
  • this.activatedRoute = activatedRoute;
  • this.router = router;
  •  
  • this.breadcrumbs = [];
  • this.groups = [];
  • this.items = [];
  • this.urlSubscription = null;
  •  
  • // When consuming a "sink" route (**), relative navigation does not seem to work
  • // very consistently (especially when redirecting). As such, all of our links
  • // that move "up" in the hierarchy will be defined as an absolute path from the
  • // feature-root.
  • this.featureRoot = "/go";
  •  
  • // Map our tree-data onto the version that includes a URL-safe "path" property.
  • // This will make it easier to render the links safely.
  • this.data = treeData.map(
  • ( treeItem ) => {
  •  
  • return({
  • ...treeItem,
  • path: encodeURIComponent( treeItem.name )
  • });
  •  
  • }
  • );
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I get called once when the component is being destroyed.
  • public ngOnDestroy() : void {
  •  
  • ( this.urlSubscription ) && this.urlSubscription.unsubscribe();
  •  
  • }
  •  
  •  
  • // I get called once after the component has been mounted.
  • public ngOnInit() : void {
  •  
  • // As the user moves up and down the tree hierarchy, the URL will be updated
  • // to reflect the user's location within the hierarchy traversal. We therefore
  • // have to listen for changes on the URL in order to re-render the tree.
  • this.urlSubscription = this.activatedRoute.url.subscribe(
  • ( urlSegments ) => {
  •  
  • // Each urlSegment contains information about both the path and the path
  • // parameters. Let's pluck out the path portion only and decode it so
  • // that we can use it as traversal instructions on our hierarchy.
  • var names = urlSegments.map(
  • ( urlSegment ) => {
  •  
  • return( decodeURIComponent( urlSegment.path ) );
  •  
  • }
  • );
  •  
  • this.renderTree( names );
  •  
  • }
  • );
  •  
  • }
  •  
  • // ---
  • // PRIVATE METHODS.
  • // ---
  •  
  • // I create a collection of breadcrumbs that mirrors the given set of names.
  • private createBreadcrumbs( names: string[] ) : Breadcumb[] {
  •  
  • // Each breadcrumb represents the ability to jump an arbitrary point in the
  • // hierarchical data. As such, it will be easiest if we map each name to a root-
  • // relative point below the feature.
  • var runningPath = this.featureRoot;
  •  
  • var breadcrumbs = names.map(
  • ( name: string ) => {
  •  
  • // Each name becomes the next URL-segment in the running path.
  • runningPath += ( "/" + encodeURIComponent( name ) );
  •  
  • return({
  • name: name,
  • path: runningPath
  • });
  •  
  • }
  • );
  •  
  • return( breadcrumbs );
  •  
  • }
  •  
  •  
  • // I filter the hierarchical data based on the given parent and type.
  • private filterData( parentID: number, type: string ) : TreeItemWithPath[] {
  •  
  • var matches = this.data.filter(
  • ( item ) => {
  •  
  • return(
  • ( item.parentID === parentID ) &&
  • ( item.type === type )
  • );
  •  
  • }
  • );
  •  
  • return( matches );
  •  
  • }
  •  
  •  
  • // I translate the given collection of names into an item ID that references the
  • // context item at the end of the name-based traversal. Returns null if no context
  • // can be found at the end of the name-path.
  • private getContextID( names: string[] ) : number | null {
  •  
  • var parentID = 0;
  •  
  • // In order to find the context, we have to start at the root of the hierarchy
  • // and then find the item represented by each step in the name-based-path.
  • for ( var name of names ) {
  •  
  • var context = this.data.find(
  • ( item ) => {
  •  
  • return(
  • ( item.parentID === parentID ) &&
  • ( item.type === "group" ) &&
  • ( item.name === name )
  • );
  •  
  • }
  • );
  •  
  • // If we found a context at this step, use the ID as the parent of the next
  • // step in the name-path traversal.
  • if ( context ) {
  •  
  • parentID = context.id;
  •  
  • // If we COULD NOT FIND a context at this step, the traversal is invalid.
  • // Break out of the current search.
  • } else {
  •  
  • return( null );
  •  
  • }
  •  
  • }
  •  
  • return( parentID );
  •  
  • }
  •  
  •  
  • // I render the current data against the given names-based traversal.
  • private renderTree( names: string[] ) : void {
  •  
  • // Locate the context within the data-tree that is represented by the given set
  • // of traversal steps. This starts at the root and then reduces the collection of
  • // names down to a given item in the data-tree (or null).
  • var parentID = this.getContextID( names );
  •  
  • // If the name-based traversal was invalid, redirect the user to the feature root.
  • if ( parentID === null ) {
  •  
  • this.router.navigate( [ this.featureRoot ] );
  • return;
  •  
  • }
  •  
  • // If the traversal worked, gather the items at current tree location.
  • this.breadcrumbs = this.createBreadcrumbs( names );
  • this.groups = this.filterData( parentID, "group" );
  • this.items = this.filterData( parentID, "item" );
  •  
  • }
  •  
  • }

Again, the point here isn't to concentrate on my specific implementation details - the goal is really to see how we can map arbitrary URLs onto view state, allowing more of the application state to be pushed into the URL.

That said, when we run this Angular application and start to navigate down through the movie hierarchy, we can see that the view-state is updated to match the URL state:


 
 
 

 
 Mapping wild card routes in Angular Router onto components and view-state traversal. 
 
 
 

As you can see, as we navigate further down in the movie data-tree, the URL is updated to reflect the user's choices. The changes in the URL segments of our wild card (**) route are then captured and used to update the view-state.

Now, to be fair, this same exact demo could have been implemented without a wild card route. In fact, because our data is ID-driven, this whole demo could have been implemented with a single ":id" parameter. We could then have taken the "id" from the route, located the node within our data-tree, and then worked backwards to define the breadcrumbs.

But, again, that's not really the point. Data isn't always ID-driven. And, data isn't always uniquely addressable. In such cases, being able to map an arbitrarily-long URL onto a single view component in Angular is really exciting. And, the more state that we can move into the URL, the more portable / shareable every view in our application becomes.

NOTE: The more state we move into the URL, the more likely the browser's "Back Button" is to work as the user expects it to work. Every time the user hits the back button and is taken to an unexpected View, it is a failure of the application to empathize with the needs of the user.

For completeness, and since it's fun to think about "fluent APIs", here's my code for the TreeBuilder that I am using to define my hierarchy of movies:

  • export interface TreeItem {
  • id: number;
  • parentID: number;
  • type: "group" | "item";
  • name: string;
  • }
  •  
  • // I faciliate the building of a hierarchical tree of data.
  • export class TreeBuilder {
  •  
  • private data: TreeItem[];
  • private id: number;
  • private idPath: number[];
  •  
  • // I initialize the tree-builder.
  • constructor() {
  •  
  • this.data = [];
  • this.id = 0;
  • this.idPath = [];
  •  
  • }
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  • // I return the current data.
  • public getData() : TreeItem[] {
  •  
  • return( this.data );
  •  
  • }
  •  
  •  
  • // I create another group and set it as the current context.
  • public group( name: string ) : TreeBuilder {
  •  
  • this.data.push({
  • id: ++this.id,
  • parentID: ( this.idPath[ this.idPath.length - 1 ] || 0 ),
  • type: "group",
  • name: name
  • });
  •  
  • this.idPath.push( this.id );
  •  
  • return( this );
  •  
  • }
  •  
  •  
  • // I create an item in the current context.
  • public item( name: string ) : TreeBuilder {
  •  
  • this.data.push({
  • id: ++this.id,
  • parentID: ( this.idPath[ this.idPath.length - 1 ] || 0 ),
  • type: "item",
  • name: name
  • });
  •  
  • return( this );
  •  
  • }
  •  
  •  
  • // I move up into the parent group context.
  • public up() : TreeBuilder {
  •  
  • this.idPath.pop();
  •  
  • return( this );
  •  
  • }
  •  
  • }

Anyway, I hope this inspired people to love the Angular router a little more.



Reader Comments

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.