Angular Material Nested Dialogs, part 1

I’ve been working with Angular Material for an application at work, and one of the big things I’ve been lamenting is the lack of a dialog stack for $mdDialog. In this series, I’m going to walk through the design process I’m using for coming up with such a structure, building it, and testing it.

Problem Statement

While popup dialogs are arguably a terrible interface design component, they do have their purposes, such as quick editing of medium-sized entities that are too small to navigate away from the page for, or interactive prompts that intentionally interrupt the user (think “Are you sure you want to perform this potentially harmful action?”).

The problem here is that Angular Material’s built-in dialog popup system, $mdDialog, doesn’t support multiple dialogs at the same time. It hasn’t since the linked issue was opened in 2014, and the issue remains open and in the Angular Material team’s backlog.

After looking through the documentation of the dialog system, it occurred to me that one should be able to use a combination of existing features to construct a dialog stack fairly easily; namely, the option to pass in a scope with a controller instead of a controller declaration (added from this issue), and the option to preserve a custom scope after the dialog is closed (discussed here). With these two factors combined, we can establish our own scope based on a given parent scope, create a controller on the scope the first time we open a dialog, and reuse the same scope across multiple instances of the dialog.

Initial Design

Okay, so since we’re building a stack, the first thing we need is a service which provides this functionality, and has a backing array. I’ll be doing my design in my own flavor of pseudocode, which borrows from Ruby, JS, and Python.

DialogStackService
    constructor($mdDialog):
        this.$mdDialog = $mdDialog
        this.stack = []

We now have a basic Dialog Stack service which creates a stack in its constructor. Now, what we want eventually is to model our call after the existing $mdDialog.show() call, so we can minimize the amount of changes we’ll have to make to our existing dialog invocations. As such, we’ll add a show() method:

DialogStackService
    constructor($mdDialog):
        this.$mdDialog = $mdDialog
        this.stack = []

    show(args):
        // TODO!

The goal of the show() method is to return a Promise which represents the outcome of that dialog, and not the resolution of the dialog itself. See, when $mdDialog opens a new dialog, it cancels the existing one by default, rejecting the Promise returned from that invocation of $mdDialog.show(); this isn’t what we want at all. What we want is for our dialogs to recognize when another dialog is added, and we want to keep the scope alive and on the stack so we can recreate the dialog later when it gets brought back up to the top, even though the actual dialog has been cancelled.

Okay. We know that we want to end up in a promise that will represent the dialog’s intended outcome, and not just the lifespan of the dialog window. So, let’s create our own promise. For this, I’m planning on using a Deferred object through the $q library provided by Angular, and just exposing the promise it generates. (Yeah, it’s ugly, but it works.) I’m also modifying the signature of both the constructor and show() so that we can pass in a parent scope as needed.

DialogStackService
    constructor($mdDialog, $q, $scope):
        this.$mdDialog = $mdDialog
        this.$q = $q
        this.$scope = $scope
        this.stack = []

    show(args, $parentScope):
        defer = this.$q.defer()
        scope = ($parentScope || this.$scope).$new()
        this.open(scope, defer, args)
        return defer.promise

For now, I’ve left the details of opening a dialog a bit hazy by pushing them off into a separate method. Let’s work on that now. We know we need to call $mdDialog.show() in here somewhere, and for the first invocation of a dialog, we’ll need to pass in a controller. We also need to ensure the scope stays alive if this controller ever is overwritten by another dialog; we can do this by telling the current dialog how it’s being closed when our system opens a new dialog. In addition to this, we need to make sure that we know how to tear down a dialog when another needs to take its place. Let’s take care of this right now.

DialogStackService
    constructor($mdDialog, $q, $scope):
        // Previous stuff
        this.OVERWRITTEN = "OVERWRITTEN";

    show(args, $parentScope):
        defer = this.$q.defer()
        scope = ($parentScope || this.$scope).$new()
        this.$mdDialog.cancel(this.OVERWRITTEN)
        this.open(scope, defer, args)
        return defer.promise

    open(scope, defer, args):
        this.$mdDialog.show({
            // Preserve what can be preserved
            templateUrl: args.templateUrl,
            clickOutsideToClose: args.clickOutsideToClose,
            escapeToClose: args.escapeToClose,
            // Write the stuff we need for creating the controller
            controller: args.controller,
            controllerAs: args.controllerAs,
            locals: args.locals,
            bindToController: args.bindToController,
            // Overwrite the scope
            scope: scope,
            preserveScope: true,
        }).then( (value) => {
            defer = this.close()
            defer.resolve(value)
        }).catch( (reason) => {
            if (reason !== this.OVERWRITTEN):
                defer = this.close()
                defer.reject(reason)
        });
        this.stack.push({scope, defer, args})

    close():
        {scope, defer, args} = this.stack.pop()
        scope.$destroy()
        if !this.stack.empty():
            {prevScope, prevDefer, prevArgs} = this.stack.peek()
            this.reopen(prevScope, prevDefer, prevArgs)
        return defer

Now, we have to implement a reopen() method… or do we? It does the same thing that open does, just… slightly differently. For one, we don’t want to pass a controller argument to $mdDialog.show(), since we already have a scope with a controller on it, and we don’t want to push the data onto the stack, since it’s already there. So, let’s modify our code a bit:

DialogStackService
    constructor($mdDialog, $q, $scope):
        // ...

    show(args, $parentScope):
        defer = this.$q.defer()
        scope = ($parentScope || this.$scope).$new()
        this.$mdDialog.cancel(this.OVERWRITTEN)
        this.open(scope, defer, args, true)
        return defer.promise

    open(scope, defer, args, createNew):
        this.$mdDialog.show({
            // Preserve what can be preserved
            templateUrl: args.templateUrl,
            clickOutsideToClose: args.clickOutsideToClose,
            escapeToClose: args.escapeToClose,
            // Write the stuff we need for creating the controller (if needed!)
            controller: (createNew ? args.controller : undefined),
            controllerAs: (createNew ? args.controllerAs : undefined),
            locals: (createNew ? args.locals : undefined),
            bindToController: (createNew ? args.bindToController : undefined),
            // Overwrite their scope
            scope: scope,
            preserveScope: true,
        }).then( (value) => {
            defer = this.close()
            defer.resolve(value)
        }).catch( (reason) => {
            if (reason !== this.OVERWRITTEN):
                defer = this.close()
                defer.reject(reason)
        });
        if createNew:
            this.stack.push({scope, defer, args})

    close():
        {scope, defer, args} = this.stack.pop()
        scope.$destroy()
        if !this.stack.empty():
            {prevScope, prevDefer, prevArgs} = this.stack.peek()
            this.open(prevScope, prevDefer, prevArgs, false)
        return defer

Now that we’ve done this, we know we can handle the core of the functionality we need for a dialog stack. This does have some caveats:

  • Dialog controllers won’t be reinitialized when a dialog is resumed; this could break dialogs which do their own polling for data or which need to be refreshed upon reload.
  • We can’t pass in our own scope to set up the dialog on the first go; in all of the dialog controllers I’ve seen, they don’t pass in an existing scope, so I’m assuming that this is okay. The code can be modified to preserve this or expose the passed-in scope as needed, but for now, I’ve left it out.
  • Dialogs which store state outside of their scope or their controller might not work properly; since I’m rebinding the whole thing, regenerating the template, and so forth upon dialog resumption, any presentation details which aren’t capable of being rebuilt from the scope and controller state alone will likely break.
  • Locals do not get passed back in when resuming a dialog; it’s the responsibility of the dialog controller to ensure that these are handled properly when the controller is first initialized.
  • Doing an Angular route transition or Angular UI-Router state transition may not clear the existing dialog stack; this should be checked for manually on route change or state change. A method for clearing the stack may be added and invoked from that point, which would look something like this:
clearStack():
    while !this.stack.empty():
        this.$mdDialog.cancel()

Moving Forward

Now that we have some pseudocode, and we’ve identified some key aspects of the behavior of our stack, we can start working on implementation and testing. We’ll be looking at this in the next blog post in the series. Stay tuned!

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s