Rendering a hasMany relation progressively


#1

Coming from a SO Question. I didn’t find a solution there and the problem looks like a discussion more than like a one way solution, so this is because I’m moving the subject here.

Disclaimer: very ember newbie here so maybe what I’m asking is too basic but I don’t find any explanation for this in the web

I have a parent object Report which contains a collection of Chart objects.

When I load the URL /api/reports/001/charts, the report 001 is requested to the API, then all the charts belonging to this report, then everything is rendered and … seconds latter the page is shown to the User.

Like this:

enter image description here

What I’m trying is to offer a more progressive rendering:

  1. The report is loaded from the API
  2. The report is shown to the User with the chart elements with a loading spinner
  3. Every chart is requested to the API individually
  4. Every chart is show to the User individually

Steps 3 and 4 happen in parallel independent for each chart.

Something like this:

enter image description here

Of course I don’t want anyone to come to me with a full solution, I’m looking more for orientation or maybe some link to where I can find inspiration.


#2

My first try was to create a Component but I saw is not really a good pattern to load data from a Component.

Then I tried to make the report.get('charts') to load asynchronous some how. So the charts are only loaded after all the page is rendered and only the chart template is missing… not goal.

Then I tried to send a collection of functions to the Component but I got problems because the Component template was rendered before the chart Model was loaded, and I don’t know how to make the Component rendering to wait until my signal…


#3

First, I’m assuming that you’re using Ember-data…correct? The thing to keep in mind initially is that if you use an async hasMany relationship, once you attempt to fetch one of the items in the relationship, it’s going to try to load the entire relationship and they will all resolve at the same time. I’d be inclined to split the relationship up so that you have a side-loaded hasMany relationship, and each of those will have an async belongsTo.

So, what if you model it like:

// report/model.js
export default DS.Model.extend({
  charts: DS.hasMany('chart', {async: false})
}); 

// chart/model.js
export default DS.Model.extend({
  chartData: DS.belongsTo('chart-data', {async: true})
});

// chart-data/model.js
export default DS.Model.extend({
  title: DS.attr(),
  data: DS.attr()
});

Then, when you query for your report, the response (if you’re using JSON API) would look like:

// api/report/1
{
  data: {
    id: 1,
    type: 'report',
    relationships: {
      charts: [{id: 1, type: 'chart'}, {id: 2, type: 'chart'}]
    }
  },
  included: [
    {
      id: 1,
      type: 'chart',
      relationships: {
        'chart-data': {id: 1, type: 'chart-data'}
      }
    },
    {
      id: 2,
      type: 'chart',
      relationships: {
        'chart-data': {id: 2, type: 'chart-data'}
      }
    }
  ]
}

…the “chart” model is side loaded so it’s available immediately and it’s “chartData” relationship will be asynchronous.

So then in your template, you’d do:

{{#each model.charts as |chart|}}
  {{#if chart.chartData.isLoading}}
    {{! Render the loading state}}
  {{else}}
    {{! finally, render the chart}}
    {{chart-component data=chart.chartData}}
  {{/if}}
{{/each}}

I think this would achieve your desired result. If you think this looks right, but your API won’t necessarily support this format, there could be some work you can do in your serializers that will get you to the same result.

Let me know if this direction might be useful.


#4

The @Spencer_Price’s suggestion inspired me something that doesn’t require to customize the API.

Instead of loading the charts I load a bunch of promises and leave to the Component to monitor when the promise has finished.

This is how it was in my initial Route:

// app/routes/charts/index.js
export default Ember.Route.extend({
  model() {
    return this.modelFor('reports/show').get('charts');
  }
}); 

And this is how it looks now:

// app/routes/charts/index.js
export default Ember.Route.extend({
  model() {
    var _self = this;

    var chartPromises =
      _.map(this.modelFor('reports/show').get('chartIds'), function(chartId){

        var chartPromise =
          new Ember.RSVP.Promise(function(resolve){
            var chart = _self.store.findRecord('chart', chartId);
            resolve(chart);
          });

        return chartPromise;
      });

    return chartPromises;
  }
});

(I am not happy of how ugly and confusing it looks right now but I didn’t find any cleaner way)

The charts/index template now looks like this:

// app/templates/charts/index.hbs
{{#each model as |chartPromies|}}
  {{chart-panel chartpromise=chartPromise}}
{{/each}}

(I was not able to make Component attribute lowerCamelCase)

Then I created the Component:

// app/components/chart-panel.js
export default Ember.Component.extend({
  chart: null,
  isInitiating: true,

  didInitAttrs: function() {
    var _self = this;

    this.get('chartpromise').then(function(chart){
      _self.set('chart', chart);
      _self.set('isInitiating', false);
    });
  }
});

(Again there are ugly things there like the “_self” thing but I don’t know how to get out of it)

And the component/chart-panel template:

// app/templates/components/charts-panel.hbs
{{#if isInitiating}}
  <h1>Chart Loading...</h1>
{{else}}
  <h1>{{chart.title}}</h1>
{{/if}}

It works… I don’t know if I’m mising something, comments and suggestions are welcome.


#5

Glad to hear that helped lead to a solution! I think your solution looks great.

And, as you mentioned, your route and components leave a little to be desired on how easy they are to read :wink: …maybe try something like this:

// app/routes/charts/index.js
export default Ember.Route.extend({
  model() {
    return this.modelFor('reports/show').get('chartIds').map(chartId => {
      // By using the '=>' arrow function, this
      // "this" is used from the outer context
      return this.store.findRecord('chart', chartId);
    });
  }
});

(So with the note regarding arrow functions, your component could also be refactored like so:)

// app/components/chart-panel.js
export default Ember.Component.extend({
  chart: null,
  isInitiating: true,

  didInitAttrs: function() {
    this.get('chartpromise').then(chart => {
      this.set('chart', chart);
      this.set('isInitiating', false);
    });
  }
});

I suspect also that in your route, you had done new Ember.RSVP.Promise for each of the findRecord calls to the store so that you can guarantee that each of the items in the array is in fact a promise (I’m pretty sure findRecord always returns a promise). But if not, might be useful to use Ember.RSVP.cast to make sure what you’re dealing with is always a promise (I think it’d be most appropriate to use in the component):

// app/components/chart-panel.js
export default Ember.Component.extend({
  chart: null,
  isInitiating: true,

  didInitAttrs: function() {
    let chartPromise = Ember.RSVP.cast(this.get('chartPromise'));
    chartPromise.then(chart => {
      this.set('chart', chart);
      this.set('isInitiating', false);
    });
  }
});

#6

Your change suggestions work :slight_smile: