Most Of Your Modal Windows Should Be Directly Accessible By Route In Angular 7.2.15
After years of working on various InVision SPAs (single-page applications) in Angular, I have come to believe - quite strongly - that most of the modal windows within an application should be directly accessible by route. This requires the modal windows to be architected with an eye towards flexibility and independence. And, ends-up providing useful hooks for Support and Documentation, not to mention a more natural and intuitive Browser "Back Button" experience. To see what I mean, I have put together a small Angular 7.2.15 demo that contains an "Add Friend" modal window that can be accessed directly from anywhere in the Angular application.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
In the early Angular.js days, creating route-accessible modal windows was challenging because the Router only provided a single "route". As such, "auxiliary routes" had to be shoehorned into the browser's query-string. With Angular 2+, however, the new Router elevated the concept of "auxiliary routes" to be a first-class citizen, allowing them to be defined right alongside the primary route. In Angular, these auxiliary routes are the perfect way to implement directly accessible modal windows.
As I alluded to earlier, creating directly accessible modal windows comes with many benefits:
It decouples the modal window from its calling context. This builds-in flexibility and allows the modal window to be activated from anywhere within the application. This sets the modal window up for use-cases that aren't obvious during early application ideation.
It allows Documentation and Support teams to directly link users to specific modal windows. This creates a far better support experience when compared to having to explain how to locate and activate a modal trigger.
It creates a more natural and intuitive "Back Button" experience since activating the modal window - via the Route - alters the Browser's History stack. This allows the user to exit or close the modal window simply by pressing the back button. And, since all modern Mouse devices provide integrated means to navigate "backwards" and "forwards", a user can close and even re-open the modal window without having to take their hand off of the mouse.
It somewhat forces the developer to decouple the view-model from the view because it breaks the direct connection between the primary view and the modal view. This means that data synchronization between views must work using an intermediary; which, in turn, creates a much more flexible application architecture.
With that said, let's look at an example. Having a modal window already implies a somewhat complicated application. But, I want to keep this demo as simple as possible. So, please forgive the fact the demo is somewhat trite; it's the smallest context that I could come up with that might help drive the point home.
The goal of the application is to compile a list of Friends. Adding a new friend happens inside a directly-accessible modal window. This new-friend modal can be triggered both from the Home view as well as from the Friends view. Data synchronization between the Modal view and the Friends view is implemented using a Runtime abstraction.
The first thing we can look at is the Router configuration. Notice that our new-friend modal window is defined inside of the "modal" router-outlet:
RouterModule.forRoot(
[
// Redirecting to keep all of the app under the "/app" prefix. This
// helps deal with some routing issues with empty segments.
{
path: "",
pathMatch: "full",
redirectTo: "app"
},
{
path: "app",
children: [
{
path: "friends",
component: FriendsViewComponent
},
// Notice that the "add-friend" view is in the "modal" outlet.
// This allows it (and most of your modals) to be shown to the
// user regardless of what is in the primary outlet. This is what
// makes Auxiliary Routes so freaking exciting!
{
path: "modal",
outlet: "modal",
component: ModalViewComponent,
children: [
{
path: "add-friend",
component: AddFriendViewComponent
}
]
}
]
}
],
{
// 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
}
)
By placing the new-friend view under a secondary outlet, it allows us to access it using the auxiliary route:
modal:modal/add-friend
Now, thanks to the magic of the Angular Router, we can place this auxiliary route right along any other route. So, given the Router configuration above, we can put it alongside the Home view:
/app/(modal:modal/add-friend)
Or, we can put it alongside the Friends view:
/app/(friends//modal:modal/add-friend)
Essentially, with the power of auxiliary routes in Angular, we can route to the new-friend modal from anywhere in our application. And, we can do so without having to disrupt the user's primary route.
Based on my experience, I find it helpful to place the modal outlet at the very top-level of the application, as a sibling to the root router-outlet. This makes the modal-window system easier to reason about; and, provides a single place in which top-level z-index
CSS properties need to be considered. For this demo, that happens in the App component:
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<p>
<a routerLink="/app">Goto Home</a>
—
<a routerLink="/app/friends">Goto Friends</a>
—
<a [routerLink]="[ '/app', { outlets: { modal: 'modal/add-friend' } } ]">
Add Friends From Home
</a>
</p>
<!-- Primary outlet. -->
<router-outlet></router-outlet>
<!-- Modal outlet. -->
<router-outlet name="modal"></router-outlet>
`
})
export class AppComponent {
// ...
}
Now, if we were to navigate to the following route:
/app/(friends//modal:modal/add-friend)
... it would open the "Friends" view in the primary outlet and it would open our "Add Friend" view in the secondary modal
outlet.
Let's look at the Friends view component. Notice that the Friends view doesn't actually do very much. It subscribes to an RxJS stream of Friends provided by the FriendsRuntime
; and, it also provides another option to open the new-friend modal window:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { Friend } from "./friends.runtime";
import { FriendsRuntime } from "./friends.runtime";
import { SubscriptionManager } from "./subscription-manager";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "friends-view",
styleUrls: [ "./friends-view.component.less" ],
template:
`
<h2>
Friends
</h2>
<p>
<a [routerLink]="[ '/app', { outlets: { modal: 'modal/add-friend' } } ]">
Add Friends From Friends
</a>
</p>
<ul *ngIf="friends.length">
<li *ngFor="let friend of friends">
<div class="friend">
<span class="friend__name">
{{ friend.name }}
</span>
<a (click)="removeFriend( friend )" class="friend__remove">
remove friend {{ friend.id }}
</a>
</div>
</li>
</ul>
`
})
export class FriendsViewComponent {
public friends: Friend[];
private friendsRuntime: FriendsRuntime;
private subscriptions: SubscriptionManager;
// I initialize the friends-view component.
constructor( friendsRuntime: FriendsRuntime ) {
this.friendsRuntime = friendsRuntime;
this.friends = [];
this.subscriptions = new SubscriptionManager();
}
// ---
// PUBLIC METHODS.
// ---
// I get called once when the component it being unmounted.
public ngOnDestroy() : void {
this.subscriptions.unsubscribe();
}
// I get called once after the inputs have been bound for the first time.
public ngOnInit() : void {
this.subscriptions.add(
this.friendsRuntime.getFriends().subscribe(
( friends ) => {
this.friends = friends;
}
)
);
}
// I remove the given friend.
public removeFriend( friend: Friend ) : void {
this.friendsRuntime
.removeFriend( friend.id )
.catch(
( error ) => {
console.warn( "The given friend could not be removed!" );
console.error( error );
}
)
;
}
}
The FriendsRuntime
class is my "state management" implementation. You can think of it as being akin to Redux or NgRX; but, without all of the hassle and ceremony. This class provides the conduit through which the Friends view and the new-friend modal view will communicate. Though, to be clear, they never communicate directly with each other - they only communicate with the shared state system. This allows the two views to operate independently of each other while still working towards a common goal.
Since the Friends view is subscribing to the friends "stream" on the shared state management service, it means that any changes made to the friends view-model will automatically show up in the Friends view. And, in this demo, friends are add in the new-friend modal window.
So, let's look at the new-friend modal window. This view is a bit more complicated because it provides a template-driven Form using NgModel
. But, you will see that - like the Friends view - it only communicates with the FriendsRuntime
state abstraction:
// Import the core angular services.
import { Component } from "@angular/core";
import { ElementRef } from "@angular/core";
import { ViewChild } from "@angular/core";
// Import the application components and services.
import { FriendsRuntime } from "./friends.runtime";
import { ModalViewComponent } from "./modal-view.component";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "add-friend-view",
queries: {
nameRef: new ViewChild( "nameRef" )
},
styleUrls: [ "./add-friend-view.component.less" ],
template:
`
<a (click)="closeModal()" class="close">
×
</a>
<h2 class="title">
Add New Friend
</h2>
<form (submit)="processForm()" class="form">
<div class="field">
<input
#nameRef
type="text"
name="name"
[(ngModel)]="form.name"
class="field__input"
/>
<button type="submit" class="field__submit">
Add Friend
</button>
</div>
<label for="add-friend-view-bulk-checkbox" class="bulk">
<input
id="add-friend-view-bulk-checkbox"
type="checkbox"
name="isBulkAction"
[(ngModel)]="form.isBulkAction"
class="bulk__input"
/>
<span class="bulk__label">
I want to add multiple friends.
</span>
</label>
</form>
`
})
export class AddFriendViewComponent {
public form: {
isBulkAction: boolean;
name: string;
};
public nameRef!: ElementRef;
private friendsRuntime: FriendsRuntime;
private modalViewComponent: ModalViewComponent;
// I initialize the add-friend-view component.
constructor(
friendsRuntime: FriendsRuntime,
modalViewComponent: ModalViewComponent
) {
this.friendsRuntime = friendsRuntime;
this.modalViewComponent = modalViewComponent;
this.form = {
isBulkAction: false,
name: ""
};
}
// ---
// PUBLIC METHODS.
// ---
// I close the modal window.
public closeModal() : void {
this.modalViewComponent.closeModal();
}
// I get called once after the view has been initialized.
public ngAfterViewInit() : void {
this.focusInput();
}
// I process the new friend form.
public processForm() : void {
if ( ! this.form.name.trim() ) {
return;
}
this.friendsRuntime
.addFriend( this.form.name )
.then(
( id ) => {
if ( this.form.isBulkAction ) {
this.form.name = "";
this.focusInput();
} else {
this.closeModal();
}
},
( error ) => {
console.warn( "There was a problem adding the friend." );
console.error( error );
}
)
;
}
// ---
// PRIVATE METHODS.
// ---
// I put the browser focus to the name input.
private focusInput() : void {
this.nameRef.nativeElement.focus();
}
}
As you can see, these two views - nay, two routes - work together to curate the list of Friends; however, they operate independently of each other, communicating only with the shared state management abstraction. This decoupling means that we've built-in the ability to trigger the new-friend modal anytime it might lend to a better user experience (UX). That includes the workflows that we know about today; and, allows for future workflows that we might develop later.
In this demo, I happen to be using my Runtime abstraction as the conduit between my views. But, that's not a hard requirement. You could use whatever implementation you like, whether that's Redux, NgRx, or Akita. You could even use a simple pub-sub (Publish and Subscribe) mechanism to propagate changes. The value-add isn't the implementation choice - the value-add is the decoupling that forces your application architecture to become more flexible.
That said, here is my FriendsRuntime
implementation for completeness:
// Import the core angular services.
import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
// Import the application components and services.
import { SimpleStore } from "./simple-store";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export interface Friend {
id: number;
name: string;
}
// NOTE: Internal state interface is never needed outside of runtime.
interface FriendsState {
id: number;
friends: Friend[];
}
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Injectable({
providedIn: "root"
})
export class FriendsRuntime {
private store: SimpleStore<FriendsState>;
// I initialize the friends runtime.
constructor() {
// NOTE: For the store instance we are NOT USING DEPENDENCY-INJECTION. That's
// because the store isn't really a "behavior" that we would ever want to swap -
// it's just a slightly more complex data structure. In reality, it's just a
// fancy hash/object that can also emit values.
this.store = new SimpleStore({
id: 0,
friends: []
});
this.addFriend( "Sarah" );
this.addFriend( "Joanna" );
this.addFriend( "Kit" );
}
// ---
// COMMAND METHODS.
// ---
// I add a new friend with the given name. Resolves to the ID of the newly-created
// friend record.
public async addFriend( name: string ) : Promise<number> {
if ( ! name ) {
throw( new Error( "Friend name required." ) );
}
var state = this.store.getSnapshot();
var id = ( state.id + 1 );
var friend = { id, name };
var friends = state.friends.concat( friend ).sort( this.sortFriendsOperator );
this.store.setState({ id, friends });
return( friend.id );
}
// I remove the friend with the given id.
public async removeFriend( id: number ) : Promise<void> {
var state = this.store.getSnapshot();
var friends = state.friends.filter(
( friend ) => {
return( friend.id !== id );
}
);
this.store.setState({ friends });
}
// ---
// QUERY METHODS.
// ---
// I return a stream for the friends.
public getFriends() : Observable<Friend[]> {
return( this.store.select( "friends" ) );
}
// ---
// PRIVATE METHODS.
// ---
// I provide the sort-operator for friends.
private sortFriendsOperator( a: Friend, b: Friend ) : number {
var aName = a.name.toLowerCase();
var bName = b.name.toLowerCase();
return(
( ( aName < bName ) && -1 ) ||
( ( aName > bName ) && 1 ) ||
0
);
}
}
As you can see, it's fairly simple. It provides asynchronous, side-effect access to an underlying simple store, which is little more than a glorified BehaviorSubject
.
There's more code in this demo; but, I don't think it provides enough value to show here in this post. If you want to view it, checkout my JavaScript demo projects repo.
Not all modal windows can operate independently of their calling context. But, I would suggest that most modal windows can operate independently in their own right. And, in fact, by building a modal window to operate independently, developers will create a more flexible application architecture; and, pave the way for a more effective and more efficient user experience (UX). And, with the auxiliary route feature in the Angular 7.2.15 router, navigating directly to a modal window becomes a trivial task with a massive ROI (return on investment).
Want to use code from this post? Check out the license.
Reader Comments
OK. This looks seriously cool, but I am trying to get my head around how you get the 'add-friend-view.component.ts' inside the modal?
In 'modal-view.component.ts':
Does the:
Represent the 'add-friend-view.component'?
I've never used a custom modal before. Always taken the short cut, by using Material's:
So, this article provides a way for me to try and understand the internals behind the modal paradigm.
I have been observing a pattern in angular applications, that is as follows: have a data table of some sort, and then have a context menu or another way to trigger 'edit' mode which brings up a modal dialog.
As per some understanding of the concepts of reusable components the modal should be 'dumb' i.e. it expects input parameters to fill in the values to be edited and when done emit event with the change.
While it is possible to have the modal in an auxiliary route, it would often not be populated unless triggered by user action and thus not really any more accessible if used in that way.
Linking to the modal can only make sense if the modal is a view of its own and can be independently constructed, in which case its flexibility is lost as it is now coupled to the action(s). On the other hand if it is preserved as flexible and reusable component it cannot work stand alone without the proper input from the parent view and in some cases as with data tables we do not really reflect the table's full state in the route itself as it would be too much work and unnecessary usually, for one because the data can no longer be there etc.
So the question is - given the predicates as described above, what are the rules of 'having an url for my dialog' really....
This is a great concept in terms of development and accessibility, like you can share a link which will directly point you to opened modal window, yet there can be other way of implementing the UI, or some modals which shouldn't be accessed by link(by a number of reasons).
In most of the projects where I tried this approach before this was really good, except for the
(modal:)
part in link, some people(clients for example) simply don't like this approach.But in general, thank you for this post, for pointing that out, as this was a struggle for me for couple of years, debates with colleagues and clients, that this is how it actually should be.
I hope this message will be spread among the web devs(not only Angular ones).
Thank you once again for the great topic
@All,
As a follow-up post, I've been noodling on
confirm()
andprompt()
style modal windows. At first, I wanted those to all be handled the same way. But, upon deeper reflection, I realized that this was just uniformity for the sake of uniformity. Once I realized this, I backed-up and started to think about these as "special" modal windows that need to be handled outside of the Router:www.bennadel.com/blog/3621-managing-confirm-and-prompt-modals-outside-of-the-router-in-angular-7-2-15.htm
I attempted to do this with a clean abstraction that keeps the various aspects of it ignorant of the rest of the implementation details.
@Charles,
Right, so the
<router-outlet>
directive is what renders the Components that you define in theRouter
routes. Essentially, the routable components get rendered as a sibling to the<router-outlet>
directive. So, considering a configuration that looks like this:... then rendering the route
//modal:modal/add-friend
would actually create HTML that looks like this:Notice that the "Add Friend" view is being rendered as a sibling to the
<router-outlet>
. The<router-outlet>
itself doesn't have any rendering dimensions.@Peter,
That's a great question. Having a "dumb" component can make building some functionality much easier to reason about at the component level. However, having a "dumb" component does not mean that you cannot also have a routable view. You would simply need to wrap the dumb component in a "smart" or "container" component - which would be routable - and which will hook up all the Inputs and Outputs.
Now, that said, editing a record could be a nice use of a routable component - you would just need to include the id of the record in the route - the same way that would for a so-called "normal" route. Given my
modal:
secondary outlet, you could have a route like this://modal:modal/edit-record/12345
.... where
12345
is a route parameter that gets used to tell the modal window which record you are editing.Now, you can trigger that from the data-grid you mentioned. Or, you could trigger it from anywhere else.
Great explanation. I think I understand now.
I was thinking that, usually, I use modals for components that I don't want accessed via a route, but, actually I can see the benefit of having a route-able approach, as well.
Anyway, it is good to know how to create both types!
Cheers for the great post.
Now, I need to have a look at your next post, about non route-able modals...
@Charles,
I think the non-routable modals stuff is probably more on-point with the kind of stuff you are doing. So, it probably won't actually show you much you aren't already doing :D The only difference might be that I am wrapping my non-routable invocation inside another service -- you might be invoking the dialogs directly? Just guessing based on a lot of what I've seen in various blogs about Google CDk.
More or less similar approaches. Just personal preference.
@Maksym,
Very cool!! I am glad that you found this interesting; and, perhaps helpful in identifying some of the benefits of this approach. I also just wrote a follow-up post on modals that don't make sense to link to directly. Not sure there is anything worth reading there -- more just some patterns I am working on.
Another great article! Thanks @Ben
@Tom,
Thank you, kind sir :D
I'm sorry but I can't agree. The title is just a bold statement. Actually it's the inverse. Most of the modals should not be linked to router. Like Peter said above a modal functions in a specific context, and having the smart-dumb components relation modals most often are dumb. The data for them comes from inputs. Linking a modal to a router makes it too specific for your router structure and params. Sure maybe sometimes I would need it. Like I have a specific case and I used that like 1 year ago, a user sees a list of items, every item has a link to an assets. In this case i would like to open a modal of image preview, but again this modal will open only in that subroute, not anywhere else.
@Zlati,
If it helps to understand my point of view, consider for a moment the world before SPAs (Single-Page Apps). In such a world, almost anything that we would currently show inside a Modal window would have been rendered as a completely different page-rendering in the older request-response model. So, something like your Image Preview example, there would almost certainly have been a link to a page, like (just rough estimate):
http://my-app.com/image-preview/:assetID
This Image Preview could then have been linked to via a bookmark, or a passed-around in an email, JIRA ticket, bug tracker, etc. And, I'm only making the case that anything you would have linked to directly in an old request-response model should also be linkable in a SPA model for all of the same reasons.
It is better, in my opinion to be able to link to a modal from everywhere and not need it than to not be able to link to a modal window and then have to build it later due to a changing business requirement.
In my experience, the majority of modal windows don't represent an action that is strictly context dependent. Most modal windows can be linked-to from, at the very least, both a List and a Detail page.
It could also be that the type of applications I am building are different and lend more closely to a shared modal model.