/ #ember-addon

Creating ember-model-select: why and how?

Creating ember-model-select: why and how?

This post talks about how ember-model-select was built by using and composing addons found in the Ember ecosystem.

The use case

Working on multiple (ember-)data and form heavy Ember apps for the past couple of years some use cases showed up where it would be necessary to search and set a variable based on data from the ember-data store. The two most prominent were:

  • Filtering on relationships
  • Setting relationships in a form

The initial solution I created and implemented consisted of an ember-power-select combined with data retrieved through a debounced ember-concurrency task. The power-select component can show a search box in which the user can, for example, type part of the user's name he wants to select. This search term is forwarded to the search task searchUsers where we use it in the query.

Searchable Power Select

//controller.js

selectedUser: null,

searchUsers: task(function* (term) {    
  yield timeout(500);    

  return this.store.query('user', {    
    page: {    
     number: 1,    
     size: 10    
    },    
    search: {    
     fullName: term    
    }
  });    
})
//template.hbs

{{#power-select
  search=(perform searchUsers)
  selected=selectedUser
  onchange=(action (mut selectedUser))
  allowClear=true
as |user|}}
  {{user.fullName}}
{{/power-select}}

A couple of problems showed up with this setup.

There is a limited amount of results that is shown (in this case 10). This is a side effect of the API being paginated. We could make the API return all records, but in certain cases that might return an immense amount of data, slowing everything down.

Furthermore, imagine ten of these select boxes implemented in a single page. That's a lot of tasks to implement and maintain!

What do we need?

In order to improve both the usability as well as the maintainability there were two things I wanted to do.

  1. Abstract the data fetching away into a component.
  2. Allow an unlimited amount of data to be shown.

The first task is easy to accomplish as we are using ember-data and only need to know the name of the model to fetch the related records. We could create a component which takes a modelName argument.

For the second improvement I wanted to use ember-infinity as it was just updated with a new service based approach instead of being hard-wired to a route. Ember-infinity is an infinite scrolling addon which takes care of loading new pages of data when they're about to be scrolled into view. This way only the data that is currently needed is loaded from the API.

Building the component

At the base of the addon is still the excellent ember-power-select. This addon provides the necessary functionality to show a searchable dropdown. The majority of our new model-select component consists of a power-select instance.

{{#power-select
  options=_options

  search=(perform searchModels)
  selected=_selectedModel

  onopen=(action "onOpen")
  oninput=(action "onInput")
  onclose=(action "onClose")
as |model|}}
  {{#if hasBlock}}
    {{yield model}}
  {{else}}
    {{get model labelProperty}}
  {{/if}}
{{/power-select}}

{{#if loadModels.isRunning}}
  <div class="ember-model-select__loading">
    {{model-select/spinner}}
  </div>
{{/if}}

We have a property _options on the model-select component which we'll use to store the current records into. We link power-select's search hook to our to-be-implemented ember-concurrency task. Furthermore we pass the currently selected model and some actions to handle the various events occurring on the power-select.

  • onopen: default options are loaded by performing the searchModels ember-concurrency task.
  • oninput: if the search term is currently empty (as a result of the user backspacing in the search box) we also load the default options.
  • onclose: when the power-select is closed we cancel all searchModels tasks that might still be running.

The selected item is shown within the block of the component. This is what will end up being rendered in the power-select's trigger. A labelProperty must be passed into the component which represents the key on the model containing a visual representation (i.e. "fullName") of the record. Alternatively it can be used in block form where the user can pass their own representation.

Finally a loading spinner is shown over the input element in order to show to the user whether or not data is currently being loaded by using our ember-concurrency task's derived state.

Implementing the searchModels ember-concurrency task

At the basis still lies the same ember-concurrency task from the start of this post. We have a model name and a search term based on which we'll query the ember-data store.

In order to show the spinner only when actual loading is happening (and not just a debounce) we split the concurrency task into two: one that will be called from the component and one that can be used for it's derived state.

store: service(),

searchModels: task(function* (term, options, initialLoad = false) {
  if(!initialLoad){
    yield timeout(this.get('debounceDuration'));
  }

  yield this.get('loadModels').perform(term);
}).restartable(),

loadModels: task(function* (term) {
  const query = {};

  if(term){
    const searchProperty = this.get('searchProperty');
    const searchKey = this.get('searchKey') || this.get('labelProperty');

    const searchObj = {};
    set(searchObj, searchKey, term);
    set(query, searchProperty, searchObj);
  }

  set(query, this.get('pageParam'), 1);
  set(query, this.get('perPageParam'), this.get('pageSize'));

  let _options = yield this.get('store').query(this.get('modelName'), query);

  this.set('_options', _options);
}).restartable()

The searchModels task will only debounce (by calling yield timeout(...)) when it is not an initialLoad. We wouldn't want to delay showing the default options when the user first opens the select box.

After the debounce is finished the loadModels task is performed which actually queries the store. Some configuration options are parsed in order to construct a proper query for the API that is used. After this we query the store and store the result in the _options property.

By now we have encapsulated the same functionality as the code we started out with into a separate component.

Integrating ember-infinity

In order to integrate ember-infinity we need to do two things:

  • Modify our loadModels task to use an ember-infinity model when infinite scroll is turned on.
  • Put the {{infinity-loader}} component at the bottom of the options list. This component loads the next page of data into the ember-infinity model.
store: service(),
infinity: service(),

loadModels: task(function* (term) {
  const query = {};

  if(term){
    const searchProperty = this.get('searchProperty');
    const searchKey = this.get('searchKey') || this.get('labelProperty');

    const searchObj = get(query, `${searchProperty}.${searchKey}`) || {};
    set(searchObj, searchKey, term);
    set(query, searchProperty, searchObj);
  }

  let _options;

  if(this.get('infiniteScroll')){
    // ember-infinity configuration
    query.perPage         = this.get('pageSize');

    query.perPageParam    = this.get('perPageParam');
    query.pageParam       = this.get('pageParam');
    query.totalPagesParam = this.get('totalPagesParam');

    this.set('model', this.get('infinity').model(this.get('modelName'), query));

    _options = yield this.get('model');
  } else {
    set(query, this.get('pageParam'), 1);
    set(query, this.get('perPageParam'), this.get('pageSize'));

    _options = yield this.get('store').query(this.get('modelName'), query);
  }

  this.set('_options', _options);
}).restartable()

Here is our updated loadModels task. After injecting the infinity service we pass the proper pagination options into the query object that is passed to ember-infinity and then call the infinity service to create a model based on our query.

In order to integrate the {{infinity-loader}} component in the proper place we'll have to pass a custom optionsComponent into power-select. In order to get the minimum of code duplication we extend the original options component and copy over the template contents into our own addon.

  import OptionsComponent from 'ember-power-select/components/power-select/options';
  import layout from '../../templates/components/model-select/options';

  import { computed } from '@ember/object';

  export default OptionsComponent.extend({
    layout,

    showLoader: computed('infiniteScroll', 'infiniteModel', 'select.loading', function(){
      return this.get('infiniteScroll') && this.get('infiniteModel') && !this.get('select.loading') ;
    })
  });
{{!-- Original options template --}}
...

{{!-- Add infinity-loader for infinite-scroll support --}}
{{#if showLoader}}
  {{#infinity-loader
    tagName="li"
    infinityModel=infiniteModel
    hideOnInfinity=true
    scrollable=(concat "#ember-basic-dropdown-content-" select.uniqueId)
  }}
    {{model-select/spinner}}
  {{/infinity-loader}}
{{/if}}

We added a showLoader computed property which will render the {{infinity-loader}} component when infinite scroll is enabled and power-select itself is not currently loading (which for example happens if the user is typing). This way it will only show when the user might interact with options list. We enhance the infinity-loader by showing a spinner component when it's loading new data.

In our original template we can now pass this component to the power-select instance.

{{#power-select
  optionsComponent=(component optionsComponent
    infiniteScroll=infiniteScroll
    infiniteModel=model
  )

  ...
as |model|}}
  ...
{{/power-select}}

...

The result

The resulting component can now be used in normal and infinite scroll variants by passing just a few properties.

{{model-select
  modelName="user"
  labelProperty="fullName"

  selectedModel=selectedUser
  onchange=(action (mut selectedUser))

  infiniteScroll=false
}}

model-select example

To try it out check: https://nickschot.github.io/ember-model-select/. There's also multiple-select and withCreate variants to be found.

There's lots of more details going on under the hood. Interested? Be sure to check it on Github or poke me on the Ember Discord.