Managing Confirm And Prompt Modals Outside Of The Router In Angular 7.2.15
The other day, I made a strong statement that most of your modal windows should be accessible by route in Angular 7.2.15. This is something I've come to believe quite strongly after working on a complex Single-Page Application (SPA) for 7-years. That said, not all modal windows are created equal. And, not all modal windows can stand on-their-own as directly-accessible Views. Take the Confirm and Prompt modals for example. Not only are they 100% dependent on their calling context; but, they may have to show over an existing modal window. In order to do this, we need to have a way to manage Confirm and Prompt modal windows outside of the Router in Angular 7.2.15.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
There are a lot of demos on the web that showcase the rendering of Modal windows as "dynamic components". For example, using the Component Development Kit (CDK) from Google, you might try opening a modal window with a line of code that looks like this:
dialog.open( MyModalComponent )
NOTE: I've never actually used the CDK, so forgive me if these statements are a misunderstanding on my part.
The problem - in my eyes - in thinking about modal windows in this way is that it tightly couples the calling context to the actual implementation of the modal. To decouple the calling context from the modal implementation, we need to create an intermediary information broker - a service that will implement an API for the modal; but, do so without caring about how the modal window is actually rendered.
Consider the "Confirm" modal. With the Confirm modal, we can create a ConfirmService that acts as the conduit between the calling context and the implementation of the Confirm modal view:
This decouples the calling context - the View that is triggering the Confirm modal window - from the implementation of the Confirm feature. At this point, you could use something like window.confirm()
as an "implementation detail" inside of the Confirm Service. Then, if you have time, you can go back and swap out the window.confirm()
approach with something more colorful like Google's CDK Dialog widget and your calling context never has to know the difference.
Such is the power of indirection!
That said, I've never actually used Google's CDK; so, I need to create "something" that acts as the go-between for the intermediary service and the View-rendering. In other words, I have to create the rendering-glue for the given Modal window. In my previous post on modal windows, this glue was the Router using an auxiliary router outlet; but, in this case, we're handing these special modal windows outside of the Router. So, I'm going to put my "glue" inside of a top-level component within my Angular application:
This "glue" component looks for changes in the Confirm service; and then, uses those changes to explicitly mount and unmount the Confirm Modal component. This approach may take a little bit more coding; but, it does have some nice benefits:
It keeps things more explicit in the application View rendering, which - at least for my caveman brain - makes code easier to reason about, understand and, maintain.
It makes it easier to style these modal windows. This is particularly important when you consider
z-index
. Since these modal windows have to show above other modal windows, it is critical that yourz-index
be meaningful; and, that your component is rendered in a part of your DOM (Document Object Model) tree that can leveragez-index
properly.It provides a single place in which you can - theoretically - manage the interplay between different modal windows. For example, you could delay rendering a "Prompt" modal if there is still a "Confirm" modal being rendered.
In my exploration, I'm putting this "glue" component inside my App Component, right alongside my top-level Router Outlets. I'm calling it SystemPromptsComponent
:
<!-- Primary outlet ( z-index: default ). -->
<router-outlet></router-outlet>
<!-- Modal outlet ( z-index: 100 ). -->
<router-outlet name="modal"></router-outlet>
<!--
System prompts and confirmations ( z-index: 200 ).
--
NOTE: These are not "routable" views (like modals). As such, we are handling
them explicitly as components references.
-->
<app-system-prompts></app-system-prompts>
Notice (from the HTML comments) that I am being very meaningful in my use of z-index
:
- Primary router outlet: default.
- Modals router outlet:
z-index
: 100 - System prompts component:
z-index
: 200
Because the Modals router outlet and my system prompts component are siblings, these z-index
values will create containers that stack properly when rendered (with the system prompts showing above the routable modal Views). Notice that I am not using something inane like a z-index
of 99999999. As I've stated in the past, if you are using a z-index
like that, you are likely operating on an incomplete understanding of how z-index
works.
Now, if we look at the SystemPromptsComponent
code, we will see that it is acting as the "glue" between the various prompt-related services and the prompt-related components:
// Import the core angular services.
import { Component } from "@angular/core";
import { NavigationStart as NavigationStartEvent } from "@angular/router";
import { Router } from "@angular/router";
// Import the application components and services.
import { ConfirmResult } from "./confirm.service";
import { ConfirmService } from "./confirm.service";
import { PromptResult } from "./prompt.service";
import { PromptService } from "./prompt.service";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-system-prompts",
styleUrls: [ "./system-prompts.component.less" ],
template:
`
<ng-template [ngIf]="confirmService.isPending()">
<app-confirm
[message]="confirmService.message"
(value)="handleConfirm( $event )">
</app-confirm>
</ng-template>
<ng-template [ngIf]="promptService.isPending()">
<app-prompt
[message]="promptService.message"
[defaultValue]="promptService.defaultValue"
(value)="handlePrompt( $event )">
</app-prompt>
</ng-template>
`
})
export class SystemPromptsComponent {
public confirmService: ConfirmService;
public promptService: PromptService;
private router: Router;
// I initialize the system-prompts component.
constructor(
confirmService: ConfirmService,
promptService: PromptService,
router: Router
) {
this.confirmService = confirmService;
this.promptService = promptService;
this.router = router;
}
// ---
// PUBLIC METHODS.
// ---
// I handle the value-emission from the confirm component.
public handleConfirm( value: ConfirmResult ) : void {
this.confirmService.resolve( value );
}
// I handle the value-emission from the prompt component.
public handlePrompt( value: PromptResult ) : void {
this.promptService.resolve( value );
}
// I get called after the inputs have been bound for the first time.
public ngOnInit() : void {
// The default browser behavior (at least in Chrome) for things like alert(),
// confirm(), and prompt(), is to cancel the prompt if the user navigates away
// from the current view. As such, we want to mimic the same natural behavior by
// closing any pending prompt when a Navigation event is detected.
this.router.events.subscribe(
( event ) => {
if ( event instanceof NavigationStartEvent ) {
this.handleNavigation();
}
// CAUTION: It may be tempting to try and block the Router with a
// CanDeactivate guard that looks to see if a Confirm or Prompt is
// pending. However, this will end up being a rabbit-hole as there is a
// known bug - 2-years in the making - in which CanDeactivate guards
// break the browser's history stack.
// --
// Read More: https://github.com/angular/angular/issues/13586
}
);
}
// ---
// PRIVATE METHODS.
// ---
// I handle the NavigationStart event, closing any pending prompts (with default
// rejection-oriented values).
public handleNavigation() : void {
if ( this.confirmService.isPending() ) {
this.confirmService.resolveWithDefault();
} else if ( this.promptService.isPending() ) {
this.promptService.resolveWithDefault();
}
}
}
As you can see from this component's template, if the ConfirmService
or the PromptService
have a pending request, this component renders the appropriate "dumb" component and then binds to the component's output events. This "glue" component then takes those output events and uses them to "resolve" the pending confirms and prompts.
Notice also that this "glue" component is listening to the Router. And, if the user navigates away from the current view, this component will resolve any pending prompts with a default value. This behavior is in alignment with the native behavior of the Browser (at least in Chrome).
The prompt-related components that are being rendered by this "glue" component are "dumb" in the sense that they have no idea how they are being used - they operate based solely on inputs and outputs. To see what I mean, let's take a quick look at the app-comfirm
component:
// Import the core angular services.
import { Component } from "@angular/core";
import { ElementRef } from "@angular/core";
import { EventEmitter } from "@angular/core";
import { ViewChild } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-confirm",
inputs: [ "message" ],
outputs: [ "valueEvents: value" ],
queries: {
yesRef: new ViewChild( "yesRef" )
},
styleUrls: [ "./confirm.component.less" ],
template:
`
<form (submit)="processForm( true )" class="form">
<div class="message">
{{ message }}
</div>
<div class="buttons">
<input
#yesRef
type="submit"
value="Yes"
class="submit"
/>
<input
(click)="processForm( false )"
type="button"
value="No"
class="submit submit--cancel"
/>
</div>
</form>
`
})
export class ConfirmComponent {
public message!: string;
public yesRef!: ElementRef;
private valueEvents: EventEmitter<boolean>;
// I initialize the confirm component.
constructor() {
this.valueEvents = new EventEmitter();
}
// ---
// PUBLIC METHODS.
// ---
// I get called once after the view has been initialized.
public ngAfterViewInit() : void {
this.yesRef.nativeElement.focus();
}
// I process the confirmation form.
public processForm( value: boolean ) : void {
this.valueEvents.emit( value );
}
}
As you can see, the ConfirmComponent
is super simple. It just renders its [message]
input and then emits a (value)
output when the user clicks a button. It doesn't know anything about the modal system or the "glue" component. It just operates in its own little, isolated context.
Now that we see how this "glue" component acts as the intermediary between the various prompt-related services and the prompt-related components, let's look at one of the prompt-related services: ConfirmService
. The ConfirmService
is intended to create an asynchronous, Promise
-based version of window.confirm()
:
// Import the core angular services.
import { Injectable } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
export type ConfirmResult = boolean;
@Injectable({
providedIn: "root"
})
export class ConfirmService {
public message: string;
private promiseResolve: Function | null;
private promiseReject: Function | null;
// I initialize the confirm service.
constructor() {
this.message = "";
this.promiseReject = null;
this.promiseResolve = null;
}
// ---
// PUBLIC METHODS.
// ---
// I open the confirm with the given message.
public confirm( message: string ) : Promise<ConfirmResult> {
if ( this.isPending() ) {
throw( new Error( `There is already an active confirmation: ${ this.message }` ) );
}
var promise = new Promise<ConfirmResult>(
( resolve, reject ) => {
this.message = message;
this.promiseResolve = resolve;
this.promiseReject = reject;
}
);
return( promise );
}
// I determine if there is a pending confirmation.
public isPending() : boolean {
return( !! this.promiseResolve );
}
// I resolve the confirm with the given value.
public resolve( value: ConfirmResult ) : void {
if ( ! this.isPending() ) {
throw( new Error( "There is no active confirmation." ) );
}
this.promiseResolve( value );
this.message = "";
this.promiseResolve = null;
this.promiseReject = null;
}
// I resolve the confirm with a default value. This is for cases in which the confirm
// has to be resolved without an explicit user-provided value.
public resolveWithDefault() : void {
this.resolve( false );
}
}
As you can see, this service is also fairly small in scope: it does nothing more than manage an internal Promise
and provide a means to solve said Promise
with a given value (or a default value if the Promise
is being resolved implicitly by the application).
So, here's what we've seen so far:
- A decoupled
ConfirmComponent
that operates only on Inputs and Outputs. - A decoupled
ConfirmService
that knows nothing about the View hierarchy or the Router or how it is being consumed. - A "glue" component that ties the
ConfirmService
to theConfirmComponent
with a meaningfulz-index
that creates "expected" stacking behavior.
It's a bit of code; but, the individual parts have all been very small in scope. The last part of the code worth looking at is the actual consumer of the ConfirmService
. For this aspect, I took my previous demo - managing a list of Friends - and added the ability to add a Friend using a "prompt" and to delete a friend only after a "confirm".
Ignoring the "state management" parts that are relevant to my last post, take notice of how this.confirmService.confirm()
and this.promptService.prompt()
are being used:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { ConfirmService } from "./confirm.service";
import { Friend } from "./friends.runtime";
import { FriendsRuntime } from "./friends.runtime";
import { PromptService } from "./prompt.service";
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>
—
<a (click)="addFriendViaPrompt()">Add Friend via Prompt</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 confirmService: ConfirmService;
private friendsRuntime: FriendsRuntime;
private promptService: PromptService;
private subscriptions: SubscriptionManager;
// I initialize the friends-view component.
constructor(
confirmService: ConfirmService,
friendsRuntime: FriendsRuntime,
promptService: PromptService
) {
this.confirmService = confirmService;
this.friendsRuntime = friendsRuntime;
this.promptService = promptService;
this.friends = [];
this.subscriptions = new SubscriptionManager();
}
// ---
// PUBLIC METHODS.
// ---
// I add a friend using the Prompt instead of the add-friend modal window.
public addFriendViaPrompt() : void {
this.promptService
.prompt( "What is your friend's name?", "Kim" )
.then(
( name ) => {
if ( name ) {
return( this.friendsRuntime.addFriend( name ) );
}
}
)
.catch(
( error ) => {
console.warn( "The new friend could not be added!" );
console.error( error );
}
)
;
}
// 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 {
Promise.resolve()
.then(
async () => {
// NOTE: This is just a silly use-case. I am performing chained
// confirmation calls just to see if it works. Note that both of the
// confirmation calls are Promise based; and, that we're using the
// async / await syntax to tie them together.
if (
await this.confirmService.confirm( `Are you sure you want to delete, ${ friend.name }?` ) &&
await this.confirmService.confirm( `Are you really really really sure?` )
) {
return( this.friendsRuntime.removeFriend( friend.id ) );
} else {
console.info( "You opted not to delete your friend." );
}
}
)
.catch(
( error ) => {
console.warn( "The given friend could not be removed!" );
console.error( error );
}
)
;
}
}
As you can see, by using ConfirmService
and PromptService
, this View is completely decoupled from the implementation details of the various modal windows. From this View's point-of-view, the ConfirmService
and the PromptService
represent nothing more than Promise
-based implementations of window.confirm()
and window.prompt()
, respectively. Everything is pleasantly ignorant of everything else.
Now, if we run this Angular application and try to delete one of the Friends, we will get the following browser output:
As you can see, the Friends view invoked this.confirmService.confirm()
. This created a pending Promise
internally. Our "glue" component then picked-up on this pending Promise
and, in turn, rendered the ConfirmComponent
using the provided [message]
input.
There's a lot more code in this demo; but it doesn't really pertain to the use of prompts - it just sets up the context in which these prompts can be explored. Feel free to look at the rest of the code on GitHub.
At first, when I started to think about how to implement Confirm and Prompt modal windows, I wanted them to act just like every other modal window. But, the more I thought about this, the more I realized that this desire was nothing but "uniformity for the sake of uniformity". These modal windows are different; and, need to be treated differently.
Ultimately, the approach that I'm taking in this Angular 7.2.15 application is just an instance of "using a service to communicate between components." This is a fundamental pattern in every Angular application. And, in this case, I'm using this pattern to implement Confirm and Prompt modal windows in a way that keeps everything cleanly decoupled.
Want to use code from this post? Check out the license.
Reader Comments