Reusable modal dialog service


#1

Let’s say you have a routable modal dialog where you have a complex form or feature. This dialog might have more steps, so it can have subroutes. This dialog can appear across your pages when you click a button. Of course, the user should see the background content, the page, the subpage, what they visited before clicking the button. Meanwhile, the dialog appears over the content.

A route map example:

Router.map(function () {

  this.route('modal-dialog', function() {
    this.route('step-one');
    this.route('step-two');
  });

  this.route('page-one');
  this.route('page-two');
  this.route('page-three', function() {
    this.route('subpage-one');
    this.route('subpage-two');
  });
});

You have a button on each page and subpage. If you redirect the user to the modal-dialog route (and the dialog renders in a different outlet of course), the normal behaviour is the dialog appear, but Ember is exiting from the subroute and the background content disappears. However, if you close the dialog, Ember goes back to the original subroute and the content is there again.

The question is, how can we implement a common or shareable route, what you can inject wherever you want, keeping that context.

I tried out this addon: https://github.com/nathanhammond/ember-route-alias which is quite good, but it is not really solving this problem and it looks, I cannot use a route as alias in a subroute if it created on the main route.

I feel, we can solve this use case only with touching something under the hood… any idea? :slight_smile:


#2

I realised, we cannot implement the above using routes. So I moved the logic in a Service which can manage the rendering process and take care of the query param management if a user navigate with the browser’s history back button.

Unfortunately, we can render in outlet only from route handlers and we can manage query params only from controllers… so this service uses the application route handler and controller to deal with this features.

You need a query param in the application controller:

// app/controllers/application.js
import Controller from 'ember-controller';

export default Controller.extend({

  queryParams: ['showDialog'],
  showDialog: false
});

and a named outlet in your application.hbs:

{{!-- app/templates/application.hbs --}}
{{outlet}}
{{outlet 'modal'}}

This is the service:

// app/services/dialog.js
import Service from 'ember-service';
import getOwner from 'ember-owner/get';

export default Service.extend({

    applicationRoute() {
        return getOwner(this).lookup('route:application');
    },

    applicationController() {
        return getOwner(this).lookup('controller:application');
    },

    open(template, model) {

        this.applicationRoute().render(template, {
            outlet: 'modal',
            into: 'application',
            controller: template,
            model
        });

        // Setup the query param and watching it, this will be called 
        // when a user uses the browser's back button
        const appCtrl = this.applicationController();
        appCtrl.set('showDialog', true);
        appCtrl.addObserver('showDialog', () => { this.close(); });
    },

    close() {

        this.applicationRoute().disconnectOutlet({
            outlet: 'modal',
            parentView: 'application'
        });

        // Remove the query param watcher
        const appCtrl = this.applicationController();
        appCtrl.removeObserver('showDialog');
        appCtrl.set('showDialog', false);
    }
});

You can inject this service in your app, the following injects everywhere:

// app/initializers/dialog.js
export function initialize(application) {
  application.inject('route', 'dialog', 'service:dialog');
  application.inject('controller', 'dialog', 'service:dialog');
  application.inject('component', 'dialog', 'service:dialog');
}

export default {
  name: 'dialog',
  initialize
};

We still need a modal component, which just a style wrapper and has a closing x in the corner. Let’s call it modal-dialog component. This dialog has a close button and a close action which calls directly the close() method in our service:

actions: {
  close() {
    this.dialog.close();
  }
}

Now you can have more templates and controllers which represents different content and modals, you can call these templates and controllers from everywhere in your application, they will always rendered on top of the actual route and content.

Let’s say you have a button which would open an instant-help form/template with this button:

<button class="btn btn-default" {{action "instantHelp"}}>Instant Help</button>

That instantHelp action will buble up, so somewhere in a parent route level has to have an action which would call the dialog service and open it:

actions: {
  instanHelp() {
    this.dialog.open('instant-help');
  }
}

Now, you can open the dialog with clicking on a button, and close the dialog with a ui button, or with the back button in the browser/mobile. But the dialog will not open again if you click on the forward button after, so it is safe.

A demo repository where I experiment with this approach: https://github.com/zoltan-nz/footnote-app/blob/master/app/services/dialog.js

Ember Twiddle Link