Setting selected model from route's dynamic segment


#1

Hello,

I would greatly appreciate some guidance on whether or not what I’ve done is the “ember” way of doing things. What I have is a basic reporting page that loads reports into a dropdown menu. When one is selected the user is shown a list of “runs” for the selected report. I set the current selected report on my reports controller for the dropdown among other things. First some code so you can see what’s going on.

app/router.js

Router.map(function() {
   this.route('reports', function() {
     this.route('report', {path: '/:report_id'});
    });
   this.route('config');
});

The template containing the dropdown of reports route looks like:

app/templates/reports.hbs

{{#power-select searchField="name"
                options=reports
                selected=selectedReport
                onchange=(action "selectReport") as |report|}}
  {{report.name}}
{{/power-select}}

{{outlet}}

When the user makes a selection I transition to the nested report route from the routes controller. app/controllers/reports.js

export default Ember.Controller.extend({
  selectedReport: null,

  actions: {
    selectReport(report) {
      this.set('selectedReport', report);
      this.transitionToRoute('reports.report', report.get('id'));
    }
  }
});

Now finally my nested route: app/routes/reports/report.js

export default Ember.Route.extend({
  model(params) {
    // Need to allow setupController to get the currently selected Report.
    this.set('report_id', params.report_id);

    return this.get('store').query('report-run', {
      report: params.report_id
    });
  },

  setupController(controller, reportRuns) {
    this._super(controller, reportRuns);

    // Get currently selected report.
    let selectedReport = this.get('store').peekRecord('report', this.get('report_id'));

    // Set on parent controller for dropdown
    this.controllerFor('reports').set('selectedReport', selectedReport);

    // Set on this route's controller for various uses.
    controller.set('selectedReport', selectedReport);
  },
});

Is this “the right” or standard way of doing this? It feels a little weird to me. In essence I need to know which report to set on the reports controller for back button support, page refreshes etc. I greatly appreciate any critique on this way of doing it.

If it helps I am using version 2.11.3 of Ember-Data and Ember.


#2

hey @bmurphy I think you’re definitely on the right track here and I wouldn’t say your code is “wrong” at all, but I might make a couple suggestions.

First, in your app/routes/reports/report.js model hook, where you’re setting ‘report_id’ on the route object, that’s a totally legit thing to do AFAIK but one thing that you may find a little more ergonomic is doing something more like this:

  model(params) {
    return Ember.RSVP.hash({ 
      reportRuns: this.get('store').query('report-run', {report: params.report_id}),
      reportId: params.report_id
    });
  },

This will just inject the report_id into your model instead of setting it on the route. Same effect except it’s maybe a little less weird-feeling and then you can use it in your template/controller without any extra work.

In fact, you may be able to get rid of the setupController method entirely. Let’s take it step by step:

    // Get currently selected report.
    let selectedReport = this.get('store').peekRecord('report', this.get('report_id'));

There’s another way you could do this, using (once again) the model hook:

  model(params) {
    return Ember.RSVP.hash({ 
      reportRuns: this.get('store').query('report-run', {report: params.report_id}),
      selectedReport: this.get('store').peekRecord('report', params.report_id),
    });
  },

Ok now let’s look at the second line:

    // Set on parent controller for dropdown
    this.controllerFor('reports').set('selectedReport', selectedReport);

It’s possible I’m misunderstanding the intent here, but your child route shouldn’t have to interact with the parent like this. The way I understand it, from a high level, your reports route should load the available reports, then your reports template renders them in the select, and the reports controller handles the selection action. That all looks good. However this line above would, I think, duplicate the ‘selectReport’ action. Meaning when you make a selection in the dropdown it sets the ‘selectedReport’ property on the reports controller, but then in the reports/report route you’re doing that a second time. That’s unnecessary. You should actually be able to just remove this line of code. The parent route/template “carries over” so you don’t need to go back up the chain and set everything when you’re down in the child route.

Now for the last one:

// Set on this route's controller for various uses.
controller.set('selectedReport', selectedReport);

We’ve actually already solved this with our solution to the first line. Once we change the model to a hash and throw the selectedReport in the model, then your report controller/template can simply reference it with model.selectedReport.

So… if you were to replace your routes/reports/report.js model hook with this:

  model(params) {
    return Ember.RSVP.hash({ 
      reportRuns: this.get('store').query('report-run', {report: params.report_id}),
      selectedReport: this.get('store').peekRecord('report', params.report_id),
    });
  },

Then remove the setupController. And then in your template/controller replace any usage of model with model.reportRuns and replace any usage of selectedReport with model.selectedReport I think it should work the exact same way without having to do any setupController shenanigans at all!

Hope that helps and wasn’t too confusing. Let me know if you have any follow up questions or issues too, I’ll keep checking back.


#3

Hey dknutsen thank you for the guidance!

It should have been obvious to simply return a Ember.RSVP.hash() from the report route model hook. Derp on my part, good catch! As for the overall decision to set the selected report on the parents controller I’ve a specific reason for doing so but I get the feeling it’s because I’m simply lacking in experience with Ember’s routing system. The action inside my routes controller that sets the selected report is what allows my dropdown of reports to show the right selection. My problem arises when I use the back button (or forwards) or I refresh the page directly to the nested route. The dropdown never gets set. Ultimately what I figured would be the right thing to do is somehow pull out the dynamic segment from the route such that the reports route can set up its controller each time and the child route could then not worry about it. Btw you were correct about my dropdown getting set twice :wink:

I happened to find another person who has the exact same problem as me. No reply to his question though.


#4

Ah I see what you mean. Yeah I remember running into that myself a while back. I’m not sure there’s a really nice solution unfortunately. The only other solution I can think of is using paramsFor(<childroute>) in the parent route to get that dynamic segment param, but I’m not sure if that works right if the parent is looking at the child params since the parent gets rendered first. The only thing that would really buy you anyway is letting the parent route manage its own state instead of the child route having to do it, but it’s not necessarily any better even if it works so… yeah I dunno. Sorry I couldn’t be of more help with that particular issue, hopefully there’s some solution out there that I’m unaware of!


#5

Not to worry. I appreciate your time.


#6

Actually I take that back. This still isn’t amazing, but one thing you could try is, in the parent route afterModel hook, look at the transition object params key and check if there are any params for the child route, If there are, add them to the model or pin them to the route object and then in setupController you can set the value if it is there. Something like:

afterModel: function(model, transition){
  if(transition.params['reports.report'] && transition.params['reports.report']['report_id']) {
    // could either pin them to route object (like below) or pin them to the model
    this.set('selectedReport', transition.params['reports.report']['report_id']);
  }
},
setupController: function(controller, model){
  controller.set('selectedReport', this.get('selectedReport'));
}

Not 100% sure that will work, and you may like your solution better anyway, but this might be a little more conventional and doesn’t require cross-route interaction.


#7

That’s not a bad idea. Trouble is executing page refreshes and using the back/forward buttons doesn’t allow the afterModel hook to execute apparently. But! I did just finish getting something to work that does allow me to stop tampering with my child route. I went rifling through the API docs and found this entry. “… it can be useful for tracking page views or resetting state on the controller” sounds about right. So on my reports route I now have:

actions: {
  didTransition() {
    let reportId = this.paramsFor('reports.report')['report_id'];
    let prevSelectedReport = this.controller.get('selectedReport');
    let selectedReport = Ember.isPresent(reportId) ? this.get('store').peekRecord('report', reportId) : null;

    // Reset state on controller so dropdown shows the right thing for page refreshes, back button, etc.
    if (Ember.isNone(prevSelectedReport) || prevSelectedReport.get('id') !== reportId) {
      this.controller.set('selectedReport', selectedReport);
    }
  },
}

This works well from what I’ve tested out. The extra ID check stops us from doing any extra work on the controller. Thanks again for sticking with me!


#8

Ah nice catch, glad you got something working!


#9

So I wanted to post an update as I’ve found some edge cases that needed handling.

The model in the report route takes some time to resolve. Since didTransition only happens AFTER the current transition finishes you don’t see the dropdown update right away.

User hammers the back/forward buttons on the browser. I attempted to use willTransition which almost worked. If the report route is still resolving (I.e. in loading state) and the user navigates to the same report route but with a different ID then willTransition is only called once on the initial navigation. Maybe someone could elaborate as to whether or not that’s expected behavior.

So what ended up finally working in the end was the following inside my reports route:

export default Ember.Route.extend({
 
    setupController(controller, model) {
        this._super(controller, model);
        this.setupReportSelection();
    },

    setupReportSelection() {
        /*
            Some explaining is needed for this. This route's controller gets decorated with
            a report which is the current selection for our dropdown in the reports template.
            Setting this in the controller's action "selectReport" works fine except when a
            user hits the back or forward buttons, refreshes the page, etc. We have to
            rely on route events/actions to set the right thing based on the url's param.

            You'll notice this is called in several places and it's because the current Router APIs
            in Ember doesn't seem to have a single place I can handle this.

            setupController:
                Page Refreshes or first time entering route.

            loading action:
                Back/Forward buttons are clicked. Note that we could opt to only rely on the didTransition
                action BUT you won't see the selection update until AFTER the transition finished.
                Not very good UX.

            didTransition action:
                User navigates from child report route to reports route manually or though back button.

            I'm not convinced this is how it should be done but it works for now :(
         */
        let reportId = this.paramsFor('reports.report')['report_id'] || null;
        let prevReportId = this.getWithDefault('controller.selectedReport.id', null);
        let currentReport = Ember.isPresent(reportId) ? this.get('store').peekRecord('report', reportId) : null;

        // Reset state on controller so dropdown shows the right thing for page refreshes, back button, etc.
        if (prevReportId !== reportId) {
            this.set('controller.selectedReport', currentReport);
        }
    },

    actions: {
        loading(/* transition, route */) {
            if (this.get('controller')) {
                this.setupReportSelection();
            }
            return true;
        },

        didTransition() {
            this.setupReportSelection();
        },
    }
});

Cheers.


#10

I’ve just faced similar issue where I have navigation bar with nested routes and want to highlight current route button in the navbar. And I’ve come up with the following solution.

Why don’t you use actions? Afaik, it is ember way to let actions bubble up from nested routes to parents. So in your inner route (Report) you can send action after model is loaded, e.g. in didTransition hook:

  actions: {
    didTransition() {
      this.send('setCurrentModel', this.modelFor(this.routeName));
    }
  }

And in the parent route (Reports) catch the action and process:

  actions: {
    setCurrentModel(model) {
      this.controller.set('currentModel', model);
    }
  }

The only downside I can think of is that you have to wait for the inner route to completely load its model. But it seems fair enough: you can’t display selected model in your dropdown unless it’s loaded. Alternatively, you might want to send action before model is loaded with model_id parameter known from the beginning.