Modal Dialogs in Web Applications
When it comes to selecting a style of modal dialog to use in your web application, you want something that is as easy to use as a native JavaScript dialog, such as the confirm function:
if(confirm('Do you agree?')) { do_it(); }
The problem that comes with JavaScript's native alert/confirm/prompt functions is that they're very limited when it comes to functionality and customization options. As an Angular developer, you may dream about the possibility of creating dialogs whose content and behavior can be defined by convenient Angular means. Perhaps showing the dialog could be wrapped in a single function call, and asynchronous processing of the dialog results could be implemented with simple and clear code.
In this article, we'll go over some relatively simple implementation of a dialog function library that matches these requirements. Dialog content and behavior in this implementation can be represented by an arbitrary Angular component, and the dialog can be shown by a dedicated function that returns a dialog result as a Promise object. We will cover these topics:
- JavaScript Promises
- Base Architecture
- Implementation
- Dialog Functions Library
- Implementing Modal Dialogs
Ready to Test It Out? Download Wijmo Today!
JavaScript Promises
A Promise is an object that allows one piece of code that waits for an asynchronous execution result (I'll refer to this as a Promise client) to communicate with the other piece of code that produces this result (I'll refer to this as the promise provider). If you don't have a full understanding of JavaScript Promises, there are several great articles that explain Promises in detail.
The Promise provider creates the Promise object, and the Promise clent can "subscribe" to this result using then().catch() methods:
promise.then(resultCallback).catch(rejectCallback);
The line above is non-blocking; it returns control immediately and execution continues on the next line of code. When a Promise provider realizes that it has a result (for example, when an XMLHttpRequest is complete), it performs a special action that causes the Promise to call the Promise client's resultCallback with the returned data. In the case of when the Promise provider detects that something went wrong (for example, an XMLHttpRequest has failed), it calls the rejectCallback with error data.
The result data passed to the resultCallback can in turn be a Promise, which means that you can create chains of the .then().catch() calls:
promise.then(result1Callback).then(result2Callback).catch(reject1Callback).then(result3Callback).catch(reject2Callback);
Base Architecture
The core showComponentInPopup function
At the core of our dialog implementation is a low-level showComponentInPopup function which is capable of:
- Creating an instance of an arbitrary Angular component, initializing its properties with specific values, and showing it in a dialog window (most likely a modal) on a screen.
- Returning a result of a user interaction with a dialog as a Promise.
Your implementation could look something like this:
showComponentInPopup(dialogWindowProperties, componentToShow, componentProperties).then(result => {/*process dialog result*/}).catch(error => {*/process error*/});
Specific Dialog Functions
Having a showComponentInPopup function in place, we can create a library of specific dialog functions:
yesNoDialog('Do you accept?').then(() => /*do something if Yes*/).catch(() => /*do something if Not*/);
or
loginDialog(ownerElement).then((data) => /*process successful authorization*/).catch((error) => /*process authorization failure*/);
...as well as many others. For this we should:
- Create a specific Angular component that will represent the dialog content, contain the results of a user's dialog interaction, and potentially contain the result of the dialog's interaction with some services.
- Create a shortcut function that will call the showComponentInPopup function and pass the component type and its initialization property values as the function parameters.
Implementation
showComponentInPopup Function
The showComponentInPopup function, as well as the library of shortcut functions, is implemented in our app/dialog.service.ts file. You can see the code for this function below:
import { Component, ComponentFactoryResolver, Injectable, ViewContainerRef, EventEmitter } from '@angular/core';
import * as wjcInput from '@grapecity/wijmo.input';
export function showComponentInPopup(popupOptions: any, componentType: any, componentProperties: any, vcr: ViewContainerRef, cmpResolver: ComponentFactoryResolver): Promise<any> {
// Create and initialize the Wijmo PopUp
let hostEl = document.createElement('div'), popUp = new wjcInput.Popup(hostEl);
popUp.hideTrigger = wjcInput.PopupTrigger.None;
popUp.initialize(popupOptions);
// Create the dialog content component instance and initialize its properties
let cmpRef = vcr.createComponent(cmpResolver.resolveComponentFactory(componentType));
if(componentProperties) {
for(let prop in componentProperties) {
cmpRef.instance[prop] = componentProperties[prop];
}
}
// Add the component to the Popup
popUp.content = cmpRef.location.nativeElement;
// Add handler to the popUp.hidden event that will destroy the popUp and contained component
let hiddenEh = () => {
popUp.hidden.removeHandler(hiddenEh);
cmpRef.destroy();
popUp.owner = null;
hostEl.parentElement.removeChild(hostEl);
popUp.dispose();
}
popUp.hidden.addHandler(hiddenEh);
// Show the PopUp
popUp.show(false);
// The function's return value, assigned with a Promise representing the dialog result
let ret = (<IPromiseResult>cmpRef.instance).result;
// Add this .then.catch branch to the returning Promise that hides the Popup after the component's 'result' will be resolved or rejected
// Note that this branch is not visible to the client code
ret.then((data) => {
popUp.hide();
}).catch((error) => {
popUp.hide();
})
return ret;
}
Let's walk through this implementation and go over some of its key parts.
Popup
We need some UI elements which are capable of showing content in a modal "window". In our example, we use a Wijmo Popup control for our UI element. For more sophisticated components, check out our Angular datagrid and more.
You're also free to use any other tools, such as a Bootstrap modal popup. The first parameter of the showComponentInPopup function is popupOptions, where you can pass a hash object with Popup-specific property values.
For example, you may want to show a dialog anchored to some DOM element, instead of centering it on the screen. In this case, you may pass
{ owner: anchorElement }
as the parameter value, which will instruct the Popup to visually stick to the anchorElement. The following piece of code creates and initializes a Popup instance with the passed Popup property values:
let hostEl = document.createElement('div'), popUp = new wjcInput.Popup(hostEl);
popUp.hideTrigger = wjcInput.PopupTrigger.None;
popUp.initialize(popupOptions);
Ready to Create Custom Dashboards? Download Wijmo Today!
Component as a Dialog Content
Now, we should instantiate a component which is used as dialog content and initialize its properties. The function defines two parameters related to the content component: componentType, which receives the type (a reference to a constructor function) of a component, and componentProperties, where you can pass a hash object with the component property values. However, there are also two not-so-obvious parameters in the function definition:
vcr: ViewContainerRef, cmpResolver: ComponentFactoryResolver
These parameters are references to the Angular services necessary for us to dynamically (in code) create an instance of a component. They can be obtained by injecting them as parameters of a constructor of a component that will call dialog functions.
If you want to learn more about dynamically creating components in Angular, the Angular team has put together some great documentation that outlines the process in more detail.
These parameters translate into the following line in our code:
let cmpRef = vcr.createComponent(cmpResolver.resolveComponentFactory(componentType));
This code creates a component and assigns the associated ComponentRef instance (holding some useful information about the component) to the cmpRef variable. The cmpRef.instance property contains a reference to the instantiated component, and cmpRef.location.nativeElement can be used to retrieve the root DOM element representing the instantiated component's template. After the component has been created, we assign its properties with the values passed in the componentProperties parameter:
if(componentProperties){
for(let prop in componentProperties) {
cmp.instance[prop] = componentProperties[prop];
}
}
After that we add the component's DOM to the Popup so that the popup's content will be the component's template:
popUp.content = cmpRef.location.nativeElement;
Finally, we're able to show the component on the screen:
popUp.show(false);
Promise as a Dialog
Any JavaScript custom dialog implementation is asynchronous by its nature. You can't implement it in the same way as the native alert, confirm, and prompt functions, where code execution is blocked until a user presses a button in the dialog. Our dialogs have to return their results at unpredictable moments dictated by a user, or even by some asynchronous services in more complex scenarios. For example, when a user presses a confirmation button in a LoginDialog, the latter could send an authentication request to the server, and wait for a response. Only after the response is received will the dialog result be ready for further processing.
The modern and probably most convenient way to handle asynchronous results is provided by Promises. If an asynchronous function result is a Promise object, the client code should just add a .then().catch() chain of calls to the function, where the handler functions processing the results are passed as parameters. This is the approach we'll use in our implementation.
The dialog results are created by our custom dialog content components and are specific to the components' semantics. The result can be exposed as a component property of the Promise type. Our showComponentInPopup function should know what the property is, be able to read this result from an arbitrary component, and return it as a function result. That is, we need to sign a contract between the function and the content component that will allow the function to know how to retrieve the result from the component.
We'll make this contract formal by introducing the following interface:
export interface IPromiseResult {
readonly result: Promise<any>;
}
Any dialog content component should implement this interface, which will store the dialog result as a Promise object returning by the result property. After putting this agreement into play, we can easily retrieve the dialog result from the component and return it as the function result:
let ret = (<IPromiseResult>cmpRef.instance).result;
return ret;
But our showComponentInPopup function must implement one more piece of functionality - it has to close the popup after the dialog result is ready (that is, the Promise will be resolved or rejected). It would be wrong to put this responsibility to a code that calls the function. For this, the function adds its own .then().catch() chain to the returning Promise, where it hides the popup.
The complete code snippet that deals with this looks as follows:
// The function's return value, assigned with a Promise representing the dialog result
let ret = (<IPromiseResult>cmpRef.instance).result;
// Add this .then.catch branch to the returning Promise that hides the Popup after the component's 'result' will be resolved or rejected
// Note that this branch is not visible to the client code
ret.then((data) => {
popUp.hide();
}).catch((error) => {
popUp.hide();
})
return ret;
So, the function has added a .then().catch() chain to the returning Promise, and an application code that calls the function will add a similar .then().catch() chain to the same Promise, in order to process the dialog results. Two different chains on the same Promise? Yes! That's absolutely something that you can do! With this, we arrive at the concept of Promise branching.
Let's take a closer look at Promise branching. The diagram below depicts the situation:
When the result is ready (either when the Promise is resolved or rejected), then both .then().catch() chains will be executed (with the topmost being executed first), and perform their job without interfering with each other - showComponentInPopup will hide the popup, and the chain in the application code will perform the required processing.
Dialog Functions Library
Now that we have the showComponentInPopup function in place, let's look at implementing a dialog function library that makes use of it. The library is represented by the DialogService class with static methods. We'll place this inside of our app/dialog.service.ts file.
Confirmation Dialogs
This is an analogue of the native confirm function, representing a dialog with confirmation text and two buttons (OK and Cancel) that allows a user to confirm or reject an action. We'll implement two different functions, okCancelDialog with OK/Cancel buttons, and yesNoDialog with Yes/No butons. The confirmation text is passed as the functions' parameter. The first step in the process of creating a dialog function is to implement a component that represents the dialog content.
For this we create the OkCancelCmp component within our app/dialog.service.ts file, whose code looks like this:
// OkCancelCmp component implements content of the Dialogs.yesNoDialog dialog.
@Component({
selector: 'ok-cancel-cmp',
template:
`<div style="padding:10px 10px">
{{question}}
<br/>
<br/>
<div>
<button (click)="clicked(true)">{{okCaption}}</button>
<button (click)="clicked(false)">{{cancelCaption}}</button>
</div>
</div>`
})
export class OkCancelCmp implements IPromiseResult {
question: string;
okCaption = 'Ok';
cancelCaption = 'Cancel';
private _result: Promise<any>;
private _resolve: any;
private _reject: any;
constructor() {
this._result = new Promise((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
});
}
get result(): Promise<any> {
return this._result;
}
clicked(ok: boolean) {
if (ok) {
this._resolve();
} else {
this._reject();
}
}
}
The confirmation text and button captions are represented by the question, okCaption, and cancelCaption properties (the latter two are assigned with 'OK' and 'Cancel' by default). They can be changed by assigning them with other values. The dialog result, according to our contract represented by the IPromiseResult interface, is exposed as the result property of the Promise type. Because this dialog result supposes only two states, confirmed or cancelled, depending on which button the user has pressed, it's convenient to consider the confirmation as a resolved Promise, and cancellation as a rejected Promise.
An application that uses our dialog would look something like this:
okCancelDialog('Do this?').then(okDelegate).catch(cancelDelegate);
The okDelegate function will be called if a user presses the OK button, and cancelDelegate will be called if the Cancel button was pressed. We create the Promise stored in the result property in the component constructor:
constructor() {
this._result = new Promise((resolve, reject) => {
this._resolve = resolve;
this._reject = reject;
});
}
The Promise constructor accepts a function with two parameters, resolve and reject, that will receive references to callback functons that client code has specified in .then() and .catch() calls. That is, provided the example above, okDelegate will be passed as the resolve parameter value, and cancelDelegate will be passed as the reject parameter. Our responsibility as the Promise object provider is to:
- Call resolve when Promise result is ready (i.e. the user has pressed the OK button). This will execute the delegate passed by the client code in the .then() call.
- Call reject when Promise is rejected (i.e. user has pressed the Cancel button). This will execute the delegate passed by the client code in the .catch() call.
For this, we store the resolve and reject parameter values in the component's private properties (_resolve and _reject, respectively).
Then, we add the clicked event handler method, which is bound to the buttons in the template, like so:
<button (click)="clicked(true)">{{okCaption}}</button>
<button (click)="clicked(false)">{{cancelCaption}}</button>
In this method we simply call _resolve or _reject depending on the passed parameter value:
clicked(ok: boolean) {
if(ok) {
this._resolve();
} else {
this._reject();
}
}
And that's it! We're done with the dialog content component implementation. Now we can easily add the dialog functions:
export class DialogService {
static okCancelDialog(question: string, vcr: ViewContainerRef, cmpResolver: ComponentFactoryResolver): Promise<any> {
return showComponentInPopup({}, OkCancelCmp, { question: question }, vcr, cmpResolver);
}
}
It calls showComponentInPopup and passes the content component type (OkCancelCmp) as a parameter. It also passes the component property values, only the question property value in this case, whose value it receives in the same named function parameter:
{ question: question }
And it passes ViewContainerRef and ComponentFactoryResolver objects that we discussed above, which it receives as function parameters:
static yesNoDialog(question: string, vcr: ViewContainerRef, cmpResolver: ComponentFactoryResolver): Promise<any> {
return showComponentInPopup({}, OkCancelCmp, { question: question, okCaption: 'Yes', cancelCaption: 'No' }, vcr, cmpResolver);
}
This is a variation of the okCancelDialog function with different button captions - Yes and No. The only difference here is that, in addition to the question property value, we also pass button caption values to the content component:
{ question: question, okCaption: 'Yes', cancelCaption: 'No' },
Ready to Test It Out? Download Wijmo Today!
Making the Library an NgModule
Dynamically created Angular components do have one nuance that needs to be addressed. Any component created by the ViewContainerRef.createComponent method should be added to some NgModule's entryComponents metadata property. To satisfy this requirement, we create a special DialogsModule module in the app/dialog.service.ts file, which references all the components used as dialog content in our dialog function library. The definition looks like this:
@NgModule({
imports: [BrowserModule, WjInputModule],
declarations: [LoginCmp, OkCancelCmp],
entryComponents: [LoginCmp, OkCancelCmp],
})
export class DialogsModule {
}
Having this NgModule, any application module that uses the library just needs to import DialogsModule in its NgModule definition.
Using the Confirmation Dialog Functions in an Application
We'll use what we've created in our app/data.service.ts file to implement Promise-based dialogs inside of our app.component.ts file. First, we'll need to import our service into our AppModule:
import { Component, ComponentFactoryResolver, Inject, ViewContainerRef } from '@angular/core';
import { DialogService } from './dialog.service';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
...
}
Then, we need to obtain references to the ViewContainerRef and ComponentFactoryResolver Angular services, as was discussed above. We do it by injecting them in the component's constructor parameters:
constructor(@Inject(ViewContainerRef) private _vcr: ViewContainerRef, @Inject(ComponentFactoryResolver) private _cmpResolver: ComponentFactoryResolver) {}
Now we can use them. Insert this code under the constructor:
yesNoDialog() {
DialogService.yesNoDialog('Do you really want it?', this._vcr, this._cmpResolver).then(() => alert(`You answered YES`)).catch(() => alert(`You answered NO`));
}
This call will display an alert "You answered YES" if users click the Yes button, and "You answered NO" if users click on the No button.
okCancelDialog() {
DialogService.yesNoDialog('Do you confirm this action?', this._vcr, this._cmpResolver).then(() => alert(`You confirmed the action`)).catch(() => alert(`You cancelled the action`));
}
This call will display an alert "You confirmed the action" if users click the OK button and "You cancelled the action" if users click on the Cancel button.
Login Dialog Simulator
For simplicity's sake, this is not a fully functional login dialog implementation. This is just a simulation of a login dialog with the following core functionality - when a user presses the confirmation button in the dialog, the dialog sends a request to a server, and depending on the authentication results (success or failure) resolves or rejects the Promise representing the dialog result.
Our login simulator dialog contains two buttons for this - simulate success and simulate failure. The dialog content is represented by the LoginCmp component in the dialog.service.ts file has the following implementation:
@Component({
selector: 'login-cmp',
template:
`<div style="padding:10px 10px">
<b>Login Dialog</b>
<br/>
<br/>
<button (click)="simulate(true)">Simulate Success</button>
<br/>
<br/>
<button (click)="simulate(false)">Simulate Failure</button>
</div>`
})
export class LoginCmp implements IPromiseResult {
private _result: Promise<any>;
onResult = new EventEmitter();
constructor() {
this._result = new Promise((resolve, reject) => {
this.onResult.subscribe((success) => {
if (success) {
resolve('Done');
} else {
reject('Access denied');
}
});
});
}
get result(): Promise<any> {
return this._result;
}
simulate(success: boolean) {
setTimeout(() => {
this.onResult.next(success);
}, 0);
}
}
We use a different technique with the login dialog to resolve or reject the resulting promise. The component declares the onResult event:
onResult = new EventEmiter();
This event will be triggered when authentication results have been received from a server. The event value keeps the authentication results (a success/failure boolean value in our case). The event is triggered in the simulate method, with a timeout that simulates asynchronous communication with the server. The result Promise is created in the constructor, and the Promise callback function subscribes to the onResult event and resolves or rejects it depending on the event data value. The Promise is resolved with a success (the "Done" string), or rejected with an error ("Access denied"), that can be inspected by the code that calls the dialog. The content component is done, the last step is to create a dialog function, loginDialog:
static loginDialog(anchor: HTMLElement, vcr: ViewContainerRef, cmpResolver: ComponentFactoryResolver): Promise<any> {
return showComponentInPopup({ owner: anchor }, LoginCmp, {}, vcr, cmpResolver);
}
In contrast to the okCancelDialog function that displays the dialog centered on the screen, we want the loginDialog to be able to show it visually anchored to some element, e.g. to a button that triggered the dialog. The element can be specified in the anchor parameter, and we assign it to the owner property of the Popup by passing the Popup options object as the first parameter of the showComponentInPopup function:
{ owner: anchor }
Now we can use the function in our application. Insert this code into the app.component.html file:
<button #button1 (click)="loginDialog(button1)">Login</button>
And this code into the app.component.ts file:
loginDialog(owner: HTMLElement) {
DialogService.loginDialog(owner, this._vcr, this._cmpResolver).then((data) => alert(`Logged in! Status: ${data}`)).catch((error) => alert(`Login failed. Error: ${error}`));
}
Note that we specify the button1 element as the owner of the dialog, so that the dialog will appear visually anchored to the button, instead of being centered on the screen. The .then() handler function uses the data parameter that holds a resolved Promise's data, and the .catch() handler function uses the error parameter containing an error information for the rejected Promise.
Implementing Modal Dialogs
In this article, we discussed one possible way to implement modal dialogs shown by function calls, whose content and behavior are defined by arbitrary Angular components, and whose dialog results are represented by Promise objects. The example code can be found here on StackBlitz.
As a tool for showing dialogs, we used Wijmo's Popup control.. For more sophisticated components, check out our Angular DataGrid and more in Wijmo.
Ready to Test It Out? Download Wijmo Today!