Modal dialogs (routes) accessible from anywhere in the app (preventing route deactivation)

I would like to create a modal dialog (route) that can be accessed from anywhere in the app, and not deactivate the current route.

This is the user flow:

  • The user is on any random route in the application
  • The user receives an in-app notification something happened on a resource (let’s say a post).
  • The notifications has a link to that resource (/posts/10) and I want it to be rendered in a modal dialog overlaying the current content
  • When the user finishes with the post/modal dialog, he should be able to close the modal and continue with whatever he was doing.

My idea is to change the route from /whatever-it-was to /posts/10 but prevent deactivating the whatever-it-was route so the content remains on the screen. I’d render the “posts” route content to a special outlet called “modal”.

Now when I close the modal I can deactivate the post route (close modal) but I don’t have to reactivate the /whatever-it-was route bacause I have it already present. This way I don’t reload all the data and I don’t lose the scroll/focus on the /whatever-it-was route.

Few reasons I want to do a transition (change the url):

  • to be able to use the route independently (bookmark the route)
  • to be able to use ember routing inside the modal dialog (I could have /posts/10/edit, /posts/10/something-else)

A working behaviour of this is Twitter:

  • when you browse your twitter feed and click on a tweet, it “opens” in a quasi-modal fashion, and the browser URL changes to the URL of the tweet
  • you can close it and return to browsing the feed
  • also, if you reload, you land on the tweet itself rendered in a modal window, and in the background is the profile page of the person who wrote the tweet

Stripe also has a similar behaviour:

  • let’s say you are on /customers route
  • now you open account setting (url changes → /account) which gets rendered in a modal overlaying your current (customers) page
  • you can continue navigating inside the modal (e.g. /account/team)
  • closing the modal takes you back to /customers but nothing is rerendered (only the modal is closed)

Does anyone have an idea how this could be achieved in Ember and could it work?

1 Like

I’m doing something similar although I know apriori what the modal will be so it’s easier to understand the template, etc…

So, the basic idea is that a modal is just another nested route. This gives it a unique url and I can send a link to a coworker and, when they open it, the app will be in the same state (i.e. modal open with the right data). The modal is styled so that it appears on top of the current page but it’s at the bottom of the DOM (makes z-index easier).

In my application template, I’ve got an outlet:

/app/templates/application.emblem

....
outlet "modal"  / this is the last line in the template

Then, I use renderTemplate in the modal’s route to put it in the right place:

/app/routes/modal.coffee

renderTemplate: ->
  @render 'modal',
    into: 'application
    outlet: 'modal'

That’s it.

Hey Andrew, thx for your reply

I agree with the modal outlet and bottom of the DOM part and I do it in the same way when rendering content into modal but I don’t know how to prevent the current route to be deactivated when transitioning to modal route.

where do you nest this modal route? On every route in your app? Because I want the modal to be opened from anywhere without deactivating the current route. Could you give me an example how your router then looks like?

Let’s say I have several routes:

   this.route('dashboard')
   this.route('tasks', function() {
     this.route('todo');
     this.route('pending');
     this.route('finished');
   });
   this.route('task', { path: ':task_id' });

I want to open the task in modal from any other route:

  • on the dashboard I have my list of tasks. When I click on a task I want to open it in a modal overlaying the dashboard (and not deactivated the dashboard route)
  • on todo, pending and finished I want to do the same thing.

If I understand Ember routing correctly the only way to do this is to nest this route everywhere I need it, like this:

   this.route('dashboard', function() {
      this.route('task', { path: ':task_id' });
   })
   this.route('tasks', function() {
     this.route('todo', function() {
       this.route('task', { path: ':task_id' });
     });
     this.route('pending', function() {
       this.route('task', { path: ':task_id' });
     });
     this.route('finished', function() {
        this.route('task', { path: ':task_id' });
     });
   });
   

but this is not an option because I don’t have 3 routes but 30-40 :slight_smile:

Unfortunately, the only way to get a named route (that I know of) is to define it everywhere you’ll need it. IMO, reusable routes would be a great thing but it’s just not supported at the moment. What I do for reuse is to put all of the logic into a mixin then use that mixin in all the routes that share the same functionality. It’s very verbose but it works.

If you don’t need these “modals” to be routed, you could use what I call a “dialog” (i.e. an ephemeral popup that can be on any page). The setup in your application template is similar except instead of a named outlet, you put in a dialog component.

/app/templates/application.emblem
...
dialog

Then, there are 2 parts to the dialog: the component and the service

/app/components/dialog.coffee
....
layout: hbs '
  {{#if dialog.open}}
    {{#g-modal dialog=true title=dialog.title}}
      {{dialog.body}}
      <div class="modal-button-container centered">
        {{#g-button action="cancel"}}Cancel{{/g-button}}
        {{#g-button primary=true action="confirm"}}Confirm{{/g-button}}
      </div>
    {{/g-modal}}
  {{/if}}
'

dialog: Ember.inject.service()

actions:
  cancel: -> @get('dialog').reject()
  confirm: -> @get('dialog').resolve()

/app/services/dialog.coffee
...
open: false
title: null
body: null
deferred: null

#
# Close and clear the dialog's contents
#
reset: ->
  @set('open', false)
  @set('title', null)
  @set('body', null)
  @set('deferred', null)

#
# Returns a promise that will resolve/reject based on the user clicking
# "Cancel" or "Confirm" in the dialog.
#
confirm: (message) ->
  @set('title', message.title)
  @set('body', message.body)
  @set('open', true)

  deferred = Ember.RSVP.defer()
  @set('deferred', deferred)
  return deferred.promise

resolve: ->
  @get('deferred').resolve('Confirmed')
  @reset()

reject: ->
  @get('deferred').reject('Canceled')
  @reset()

What’s going on here is that we’ve defined a method confirm() in the dialog service that will open the dialog and return a promise. The dialog has 2 buttons on it: Cancel and Confirm. When the user presses the Cancel button, the dialog is closed and the promise is rejected. When they press Confirm, the dialog is closed and the promise is resolved.

Then, opening a confirmation dialog would look like this:

/app/components/my-destructive-component.coffee
...
dialog: Ember.inject.service()
...
actions:
  delete: ->
    category = @get('selected.firstObject.category')
    message =
      title: "Delete #{category.toLowerCase().capitalize()}?"
      body: "Are you sure you want to delete this
            #{category.toLowerCase()}?"

    @get('dialog').confirm(message).then(
      (confirm) =>
        @get('selected').forEach (task) =>
          task.destroyRecord().then => @get('content').removeObject(task)
      (cancel) -> null)

Maybe you could pass a query param when you want to open a modal and have your controller use something like this dialog service to open the modal for you. That would give you a url that opens a modal without all the extraneous routes.

Thank you andrew for your elaborate answer. Unfortunately “reusable routes” is exactly what I need because I want to nest routes inside the modal. Do you know if there are any RFCs or discussions regarding reusable routes in Ember?

Also I have already tried the “query param” solution and it works ok for “simple” modals (no nested routes). But if you want nested routes inside this modal then you have to write all the routing logic by yourself. I’m looking for a solution where I could use Ember’s routing.

Your dialog component is great and I have a similar solution I use for “confirm” modals, etc.

If you read the old Ember docs, “resources” were billed as being reusable … but they weren’t. I can’t find the link to the github issue but the documentation never matched the implementation.

The way I do “reusable” routes is to make a mixin:

// app/mixins/baz-route.js
export default Ember.Mixin.create(
  {
    // all your logic here
  }
)

Then, if you have routes foo.baz and bar.baz

Router.map(function () {
  this.route('foo', function () {
    this.route('baz');
  });
  this.route('bar', function () {
    this.route('baz');
  });
});

And, then you have your 2 nested routes:

// app/foo/baz/route.js
import BazRoute from 'application/mixins/baz-route'

export default Ember.Route.extend(
  BazRoute,
  {
  }
 )

// app/bar/baz/route.js
import BazRoute from 'application/mixins/baz-route'

export default Ember.Route.extend(
  BazRoute,
  {
  }
 )