Recently, we completed an overhaul of our platform’s interface. One of the improvements made was introducing the ability to sort list items.
Our goal was to optimize both aspects of the sorting: the typical drag-n-drop interaction and the persistence of the newly sorted list.
For the remainder of this post, we will refer to both of these behaviors as either reordering or ranking, depending on the context. Reordering will refer to sorting elements in the UI, while ranking will refer to the act of assigning a position to the elements in a collection so the order persists between page loads.
Why reordering / ranking is a necessity
Reordering / ranking is required when the order of the models within a platform has significance and users need the ability to freely sort them. Maintaining a sortable list is a common challenge with many use cases. For example, one list in our platform is sortable for purely cosmetic reasons so the user can tidy up their workspace. In contrast, the order of items for another list in our platform governs a crucial part of our business logic in how it determines what content is shown to recipients of the content.
Mapping out the workspace
The workspace that our users spend most of their time in is structured as a typical master-detail pattern. Users navigate between items on the left by selecting them, where further information and actions can be found on the right for the selected item. Both panes contain lists with sortable items. Items can be moved in any direction within their own list, but cannot be dragged across different lists.
Implementation
ember-sortable
We use EmberJS, a Javascript web framework, to build our platform. Ember features a strong community-backed addon ecosystem. The addon that we use for the sorting component is ember-sortable. It was an easy to integrate solution that exposes a simple API that made development of the feature very straightforward.
ember-sortable
only handles the DOM interaction however. We still needed a way to actually update the underlying models behind the elements on the page. That way whenever these records are reloaded from the server, they will appear in their correct order.
We had a couple of shortcomings with our initial implementations of this updating service which offered a great learning experience.
Initial shortcomings
Whenever an item is moved within an ember-sortable grouping, the addon exposes a reference to the list of items in their current order. Our first pass at implementing the reordering feature was a simple forEach
that would set the position
value on a model to the current index in the iteration. We then save each model which sends off a PUT
request for that particular model. This was easy enough and we were able to get the feature into users’ hands for feedback.
The feature was well received and the implementation worked well for a couple of months, until we began to receive reports of slow performance in the application from users. The worst performance was observed in workspaces that had very long lists. Whenever items were sorted or duplicated, the application would become unresponsive for a short amount of time, proportional to the length of the list.
Astute readers have probably realized the obvious flaw with this initial implementation. We were firing off a network request for each element in the list, regardless of if they were actually moved in the list. This is very inefficient and fixing it required a more clever solution. We had a couple of easy-win optimizations that we performed right away:
- When swapping two elements next to each other, simply swap their
position
values - Only make a network request for models that contained dirty attributes, i.e. had their
position
values changed.
Another condition that we needed to consider was that whenever a user duplicates an element, we insert the newly duplicated element directly below (or sometimes above) the original element. The duplicated element will have the same position
value as the original element, creating uncertainty in how the elements should be presented. We can sort by a second metric, such as a record’s createdAt
timestamp. This solution works as long as a user always chooses to duplicate the most recently duplicated element. We cannot guarantee this behavior, so there will be instances where duplicated elements are placed two or more spaces separated from the original. This means that resolving position
collisions is a requirement, and in the worst case scenario, we will need to do a full list reorder (back to our original problem).
ember-ranked-model
We needed a solution that could reduce the number of network calls. Preferably we would only tell the server to update the records that actually need to be updated. The implementation that we chose is heavily inspired by ranked-model. For those unfamiliar with it, it is a Ruby on Rails library that efficiently ranks database rows using an integer column (e.g. position
).
Our ember-ranked-model
module functions similarly. For more information on how it works, you can visit its GitHub page.
Given that all of our use cases involve a single element being dragged in the list to a new location, in the best case scenario we only ever have to update the dragged item.
A basic example:
actions: {
// ember-sortable provides these two arguments
reorder(models, dragged) {
Ranker.create({models}).update(dragged).save();
}
}
Our Ranker
object takes an array of objects that is assumed to be the list in the newly unordered state. We are also given the dragged
item by ember-sortable
, which helps us determine which items need to be updated. There is also support for options such as ranking on a key other than position
, descending and ascending lists, and setting min and max caps on the rank values.
Alternative
We try to design our API to be as RESTful as possible. That means avoiding creating custom endpoints that do one particular action, i.e. a bulk ranking endpoint. A bulk ranking endpoint that takes a collection of ids in the correct order would also have been a solution. It would require no operations on the front end and only one network request. All that would be needed is the ember-sortable
integration. It could even use the actual ranked-model
in the backend since we are using Rails!
Lessons
Iteration is important
Despite the significant value of the feature, it was surprisingly simple in scope and was quick to develop. Though it wasn’t intended to be the driving aspect for the redesign, it rapidly became became one of the most commonly used features.
Test the action under similar conditions
Our initial implementation’s inattention to performance helped us learn a bit more about how our platform was being used in some peculiar use cases by our users. As mentioned before, users were creating irregular campaigns with extremely large lists. Although few in number, these campaigns were almost impossible to work on while the performance issues were still present. Our inattention was compounded by the fact that in our development environment, we build smaller campaigns with shorter lists to test behavior, along with having a more predictable and faster internet. The performance issue would not have been very noticeable in development, but in production the problem would have been exacerbated by factors such as network latency and differences in hardware. Now that we know about the existence of these campaigns, we are more conscientious about platform performance going forward. For example, we now utilize the Chrome Inspector’s network throttling feature to help simulate experiences that users with slower connections might have. By testing under similar conditions, we get closer to reproducing our users’ behavior.
Conclusion
ember-ranked-model
is something that we use in our platform in multiple places. Our users depend on the behavior being available so they can organize their workspace and edit their campaigns to the proper configuration. We found this library very useful to help expand the sorting feature into multiple areas of the application, and through open sourcing it, we hope others working on front end systems can easily do the same.