Skip to content

Instantly share code, notes, and snippets.

@ilucin
Forked from janvarljen/branched-routing.md
Last active August 19, 2016 23:30
Show Gist options
  • Select an option

  • Save ilucin/9d8bf81d0907bdcea2f1e6d89930dc40 to your computer and use it in GitHub Desktop.

Select an option

Save ilucin/9d8bf81d0907bdcea2f1e6d89930dc40 to your computer and use it in GitHub Desktop.
Ember branched routing

The problem

Applications often need to expose a part of the app in different context without breaking the current context. The simplified example would be the "settings" modal. You want to be able to open the settings modal from everywhere in you app without losing the current context.

Let's say we have a really simple app:

Router.map(function() {
  this.route('projects', {}, function() {
    this.route('dashboard');
    this.route('tasks');
    this.route('history');
  });

  this.route('settings', {}, function() {
    this.route('notifications');
    this.route('security');
    this.route('integrations');
  });
});

when we transition from projects.tasks to settings.integrations we lose all the context from the screen because the current route is deactivated and the outlet is disconnected. We don't want that but to open the settings in a modal overlaying the current context. To achieve that we would have to do something like this:

Router.map(function() {
  this.route('projects', {}, function() {
    this.route('dashboard', function() {
      this.route('settings', {}, function() {
        this.route('notifications');
        this.route('security');
        this.route('integrations');
      });
    });
    this.route('tasks', function() {
      this.route('settings', {}, function() {
        this.route('notifications');
        this.route('security');
        this.route('integrations');
      });

    });
    this.route('history', function() {
      this.route('settings', {}, function() {
        this.route('notifications');
        this.route('security');
        this.route('integrations');
      });
    });
  });
});

We actually nested the settings routes inside every other route in the application.

Now we have two problems here:

  • we need to create these nested routes for every route which is unsustainable even for small apps
  • we need to create a route/template pair for each combination of nested routes

The solution

Integrate something we call "Branched routing" into Ember. Define a route branch which can be plucked anywhere in your route tree.

Proposed API:

Router.map(function() {

  this.branch('settings', {}, function() {
    this.route('notifications');
    this.route('security');
    this.route('integrations');
  });
  
  this.route('projects', { branches: ['settings'] }, function() {
    this.route('dashboard');
    this.route('tasks');
    this.route('history');
  });
});

Every route inside projects now has a branched settings route.

projects.tasks.settings.notifications
projects.tasks.settings.security
projects.tasks.settings.integrations
...

are all valid routes which render the "branched" part in the outlet inside the projects.tasks template thus not lossing the current context of the app while keeping the normal Ember routing and templating functionality.

Futhermore we would need a solution for route/template lookup because we don't want to have all the combinations of routes/templates.

We propose something like:

braches/settings/notifications/route.js
braches/settings/notifications/template.hbs
...

Then the lookup mechanism should know to look for the code there when inside a branched route.

All this is a high-level idea I would like to discuss with the community.

Implementation of "branched routing" with engines

This was a big issue for our project (obviously it's not only about the settings screen) so we decided to find a way of implementing this behaviour. There were few ideas:

  • To extend (hack) ember routing to support this
  • To use query parameters and implement everything with components that emulate API of routes
  • To try with mountable ember engines

Engine approach seemed most reasonable to us so we gave it a go. We managed to implement it using the similar API as proposed above, only with many hacks behind the screen. The whole thing is reasonably stable and in production but the code was written in a way it can be "turned off" at any moment with a config flag (if something goes wrong in the future versions of ember or ember-engines).

So, lets see what we had to do to make our "settings screen" work.

Important note

All the code for "settings screen" still lives in the /app dir and not in the /lib. Engine is used only to make "branched routing" work and it imports everything from /app. The point of all this is that we hide the detail of using engines as much as possible in our app. For example, when we write {{link-to}}s we don't care if it is an engine route or not.

1. Create an in-repo-addon engine (call it "modal-engine")

We could have called it "settings-engine" but in our case we had multiple route branches (/settings/..., /task/...) that we need to open everywhere so we didn't want to create separate engine for each one.

2. Adjust apps router.js and mount modal-engine

Mount it in every route where you could be opening your settings screen:

// app/router.js

Ember.RouterDSL.prototype.mountModal = function() {
  if (config.isModalEngineEnabled) {
    this.mount('modal-engine', {as: 'm'});
  }
};

Router.map(function() {
  this.route('organization', function() {
    this.mountModal();

    this.route('projects', function() {
      this.mountModal();
    });

    this.route('tasks', function() {
      this.mountModal();
    });

    // ...

    // We also need to add settings route in the app's router map
    this.route('settings', function() {
      this.route('notifications');
      this.route('security');
      this.route('integrations');
    });
  });
});

3. Configure your engine in your app-space

Since we don't want isolation in our engine we need to expose all routes and services to the engine:

// app/app.js

const App = Ember.Application.extend({
  modulePrefix,
  podModulePrefix,
  Resolver,

  engines: {
    modalEngine: {
      dependencies: {
        externalRoutes: {
          'organization': 'organization',
          'organization.projects': 'organization.projects',
          'organization.tasks': 'organization.tasks'
          // ...
        },
        services: [
          // list of all services in app/services
        ]
      }
    }
  }
});

We need to do this so we can normally make transitions from engine to app routes. This is also a problem because we need to maintain the list of external routes and services manually (maybe it could be automated somehow).

4. Setup engines routes.js file

Define all routes for your engine:

// lib/modal-engine/addon/routes.js

import buildRoutes from 'ember-engines/routes';

export default buildRoutes(function() {
  this.route('settings', function() {
    this.route('notifications');
    this.route('security');
    this.route('integrations');
  });

  this.route('some-other-branched-route');

  // ...
});

So with this configuration we now have these routes in our app:

  • organization.m.settings
  • organization.m.settings.notifications
  • organization.m.settings.security
  • organization.m.settings.integrations
  • organization.m.some-other-branched-route
  • organization.projects.m.settings
  • organization.projects.m.settings.notifications
  • ...

5. Create external to engine route map

It was important for us that the whole engine thing can be completely turned off with only one config flag. That's why all the code for branched routes has to live in the /app directory and engine (/lib) is only importing from /app. That also means we have to have "default" place for those route branches in app router. So, when we want to link to our setting screen we would just use that default route path:

{{#link-to 'organization.settings'}} Settings {{/link-to}}

but, depending on the current route path, it would be transformed to organization.m.settings or organization.projects.m.settings or something else (implementation of route transformer is shown later). To be able to perform those transformations, we need to build a hash map where each external route points to engine route.

// app/modal-engine-config.js

externalToEngineRouteMap = {
  'organization.settings': 'settings',
  'organization.settings.notifications': 'settings.notifications'
  'organization.settings.security': 'settings.security'
  'organization.settings.integrations': 'settings.integrations'
};

6. Proxy all modules & templates in engine

All the modules & templates that would be used in the engine routes has to be proxied from app-space to engine-space. Since support for sharing of dependencies other than services and route paths is under consideration this step may be unecessary in the future versions.

Basically we need to proxy components, helpers, routes and maybe some other custom modules like validators, form objects, etc.

Components can be proxied like this:

// lib/modal-engine/addon/components/form/input-field.js

import Component from 'app-name/components/form/input-field/component';
import Template from 'app-name/components/form/input-field/template';
export default Component.extend({layout: Template});

Helpers can be proxied like this:

// lib/modal-engine/addon/helpers/format-date-js

export {default} from 'app-name/helpers/format-date';

Routes can be proxied like this:

// lib/modal-engine/addon/routes/settings.js

import SettingsRoute from 'app-name/routes/organization/settings';
import EngineRoute from 'modal-engine/mixins/engine-route';
export default SettingsRoute.extend(EngineRoute);

You also have to proxy everything from all the plugins you use (helpers, components etc).

Since route templates can't be proxied like that we have to make simlinks for them in the /lib directory (I know, it's not the best idea ever).

7. Handle issues with routes API

As you can notice, we're injecting engine-route mixin into all engine routes. We need to "fix" modelFor, controllerFor and transitionTo depending on wheater we're using them with external or engine routes.

Here's the implementation:

// lib/modal-engine/addon/mixins/engine-route.js

import Ember from 'ember';
import {externalToEngineRouteMap} from 'your-app/modal-engine-config';

const {inject, getOwner, RSVP} = Ember;

export default Ember.Mixin.create({
  store: inject.service('store'),

  modelFor(routeName) {
    const engineRoute = externalToEngineRouteMap[routeName];

    // If we're looking for model inside of the engine - just use default implementation
    if (engineRoute) {
      return this._super(engineRoute);
    }

    // Hacky way to get to the app container instance
    const appContainer = getOwner(this.get('store'));

    // With appContainer we can load any app route and it's context
    const appRoute = appContainer.lookup(`route:${routeName}`);

    if (appRoute) {
      const appRouteModel = appRoute.modelFor(routeName);

      if (appRouteModel) {
        return appRouteModel;
      }
    }

    // Desparate try if we didn't find our model yet
    return this._super(routeName);
  },

  controllerFor(name, ...args) {
    return this._super(externalToEngineRouteMap[name] || name, ...args);
  },

  transitionTo(routeName, ...args) {
    const isExternalRoute = !externalToEngineRouteMap[routeName];

    if (isExternalRoute) {
      this.transitionToExternal(...arguments);

      // transitionToExternal doesn't return a promise so we're mocking it
      return RSVP.resolve();
    }

    return this._super(externalToEngineRouteMap[routeName], ...args);
  }
});

8. Rewrite link-to behaviour

Reopen link-to component (you have to do similar thing to href-to helper if you're using it):

import Ember from 'ember';
import config from 'app-name/config/environment';
import {tranformRoute, trimModelsForRoute, normalizeRouteName} from 'app-name/utils/modal-engine';

const {LinkComponent, inject, computed} = Ember;

LinkComponent.reopen({
  routingService: inject.service('-routing'),

  currentRouteName: computed('routingService.currentRouteName', function() {
    return normalizeRouteName(this.get('routingService.currentRouteName'));
  }),

  init() {
    this._super(...arguments);
    if (this.attrs.modal && config.isModalEngineEnabled) {
      this.addObserver('currentRouteName', this, () => this.rerender());
    }
  },

  transformRouteForModelEngine() {
    if (this.attrs.modal && config.isModalEngineEnabled) {
      this.set('targetRouteName', tranformRoute(this.get('targetRouteName'), this.get('currentRouteName')));
    }
  },

  _getModels(props) {
    const models = this._super(...arguments);
    if (this.attrs.modal && config.isModalEngineEnabled) {
      return trimModelsForRoute(props[0], models);
    }
    return models;
  },

  willRender() {
    this._super(...arguments);
    this.transformRouteForModelEngine();
  }
});

export default LinkComponent;

Same goes for {{href-to}} if you're using it:

import Ember from 'ember';
import HrefToHelper from 'ember-href-to/helpers/href-to';
import config from 'app-name/config/environment';
import {tranformRoute, trimModelsForRoute, normalizeRouteName} from 'app-name/utils/modal-engine';

const {computed, inject} = Ember;

const HrefTo = HrefToHelper.extend({
  routingService: inject.service('-routing'),

  currentRouteName: computed('routingService.currentRouteName', function() {
    return normalizeRouteName(this.get('routingService.currentRouteName'));
  }),

  compute(params, opts) {
    if (opts.modal && config.isModalEngineEnabled) {
      let args = [];
      args.push(tranformRoute(params[0], this.get('currentRouteName')));
      args = args.concat(trimModelsForRoute(params[0], params.slice(1)));
      return this._super(args);
    }

    return this._super(params);
  }
});

export default HrefTo;

Implement route transformer that will be used in {{link-to}}, {{href-to}} or other manual route transitions and that will take current route into account.

// app/utils/modal-engine.js

import config from 'app-name/config/environment';
import {externalToEngineRouteMap, deadEndRouteMap} from 'app-name/modal-engine-config';

const mountPoint = config.modalEngineMountPoint;

function tranformRoute(targetRoute, currentRoute) {
  const engineRoute = externalToEngineRouteMap[targetRoute];

  if (!config.isModalEngineEnabled || targetRoute === currentRoute || !engineRoute || !currentRoute) {
    return targetRoute;
  }

  // Loading and error routes should never be used as a base route
  let baseRoute = currentRoute.replace('_loading', '');
  if (baseRoute.indexOf('_error') > 0) {
    baseRoute = 'index';
  }

  // Handle the case when currentRoute is a dead end route and modal can't be opened over it
  // (modal route doesn't exist). Try to map it to the first parent route where modal can be opened.
  baseRoute = deadEndRouteMap[baseRoute] || baseRoute;

  // If, for some reason, we're already in modal route space - take only non-modal part as base
  baseRoute = baseRoute.split(`.${mountPoint}.`)[0];

  return `${baseRoute}.${mountPoint}.${engineRoute}`;
}

// Normalize route name by droping the last '.index' suffix
function normalizeRouteName(routeName) {
  const name = routeName || '';
  return name.indexOf('.index') === name.length - 6 ? name.slice(0, name.length - 6) : name;
}

// Currently we're only taking the last model for route generation, hacky but good enough for now
function trimModelsForRoute(route, models) {
  return models.slice(models.length - 1);
}

export {tranformRoute, trimModelsForRoute, normalizeRouteName};

9. Take care of dead end routes

Since {{link-to}} is rerendering on every route change, it can occurr that it tries to generate modal-engine route path over some leaf routes where modal can't be opened. We call them "dead end routes". Route transformer is handling this situation by using deadEndRouteMap where dead end route points to first parent that can open that modal route.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment