What's the best way to handle bulk actions of list items?

Howdy,

Long time no post, but I ran into a seemingly simple UI pattern that I wasn’t sure how to implement using Ember and wanted to get some second opinions before moving forward.

We have a fairly standard list view; a parent component that renders a bunch of list items. Each list item can toggle a “detail view” that shows or hides more information. To make our users happy, the parent component will have the ability to toggle all list items at once.

mockup

How should I set this up? Is there a good pattern within the Ember community to do this?

My first instinct is to keep track of the open/closed states within some kind of array in the parent component, but I think I would prefer if the list items themselves were responsible for tracking their own state.

1 Like

In Ember we prefer DDAU but this is one of those cases where a parent needs to send a signal to the children.

One pattern we sometimes use in Discourse is an event emit/subscribe. Ember comes with a mixin to handle this. What I would suggest is creating a service that exposes an instance of an object with that mixin. You could call it appEvents for example.

Then, in the parent action:

actions: {
  hideAll() {
    this.appEvents.trigger('list-items:hide-all');
  },
  showAll() {
    this.appEvents.trigger('list-items:show-all');
  }
}

Then in your list component:

_hide() { ... hide code ... },
_show() { ... show code ... },

didInsertElement() {
 this.appEvents.on('list-items:hide-all', this, this._hide);
 this.appEvents.on('list-items:hide-all', this, this._show);
},

willDestroyElement() {
 this.appEvents.off('list-items:hide-all', this, this._hide);
 this.appEvents.off('list-items:hide-all', this, this._show);
}
3 Likes

We’ve done something similar to what @eviltrout proposed, but we’ve ended up using something a bit different. We keep all the state in the parent component and don’t use a service.

// parent component

export default class ParentComponent extends Component{

  // assuming ids that are strings.
  expandedItemIds: Array<string> = []

  @action 
  expandItem(itemId){
   this.expandedItemIds.addObject(itemId)
  }
  
  @action 
  collapseItem(itemId){
    this.expandedItemIds.removeObject(itemId)
  }
  
  @action
  expandAll(){
    let ids = this.items.map(item => item.id)
    this.set("expandedItemIds", ids)
  }
   
  @action
  collapseAll(){ 
     this.set("expandedItemIds", [])
   }
}

For the template, we’re using contains from https://github.com/DockYard/ember-composable-helpers#contains

{{!-- parent-component.hbs --}}
{{#each @items as |item|}}
    <ItemComponent
     @item={{item}}
     @collapseItem={{fn this.collapseItem item.id}}
     @expandItem={{fn this.expandItem item.id}}
     @isExpanded={{contains item.id this.expandedItemIds}} 
    />
{{/each}}
5 Likes

If the parent doesn’t need to know which ones are expanded/collapsed couldn’t you handle everything in the child component like?:

isExpanded: false,

click() {
 this.toggleProperty('isExpanded')
}

… need to think about toggling all of them at once though

2 Likes

I think @eviltrout’s way is fine. But also:

That’s a good instinct, and if the requirements grow any more complex than just “open all” / “close all”, it’s probably the way to go. It doesn’t need to be awkward:

export default class extends Component {
  @tracked
  openedItems = new Set();

  @action
  openAll() {
    let openedItems = new Set();
    for (let item of this.items) {
      openedItems.add(item);
    }
    this.openedItems = openedItems;
  }

  @action
  closeAll() {
    this.openedItems = new Set();
  }

  @action
  openOne(item) {
    let openedItems = { this };
    openedItems.add(item);
    this.openedItems = openedItems;
  }

  @action
  closeOne(item) {
    let openedItems = { this };
    openedItems.delete(item);
    this.openedItems = openedItems;
  }
}
{{#each this.items as |item|}}
  <ListItem @item={{item}} @isOpen={{in-set item this.openedItems}} @onOpen={{this.openOne}} @onClose={{this.closeOne}} />
{{/each}}
// app/helpers/in-set.js
export default helper(function inSet([item, set]) {
  return set.has(item);
}
7 Likes

Great suggestions!

I really appreciate the guidance here. I sometimes wish we had a little collection of UI patterns and the best way to implement them in Ember.

2 Likes

I wrote mine before I saw @ScottAmesMessinger’s reply. His is basically the same pattern, and his is more useful as an example if you’re not using tracked properties yet.

4 Likes

I use the exact same pattern that @ScottAmesMessinger proposes but I think you need to be aware of two things.

First is that you need to have an unique way to identify each list item, as an id or something. If you don’t have an id and you are iterating the list with the {{#each list as |item index|}} helper you could use the index param as identifier, but if you change the order of the list while you have expanded items then that identifier is useless. If you can’t identify each list item the @eviltrout approach is better because each element is aware of its own status and will respond to the hide-all or show-all event accordingly. The same with @ef4 code because you store the full item.

Second is a question. What about the performance? I don’t know much about it and I haven’t done any test but I suppose that working with an array of ids is more performant than doing it with a Set as @ef4 suggests, isn’t it? I suppose that is less memory consuming and faster to work with an array of strings or integers than a Set of objects, but as I say I’m not an expert and is only an idea that came to mind when I read the answer.

In any case, I’m glad to read questions and answers like these becase all of them are super useful and I have learned something new.

1 Like

I don’t think the choice of data structure here is likely to have a measurable impact until you have tens of thousands of items. And maybe not even then. Native types like Array and Set are quite fast and don’t interact at all with the slower / more complex parts of the browser API surface, like the DOM.

2 Likes