80

I have an AngularJS directive that renders a collection of entities in the following template:

<table class="table">
  <thead>
    <tr>
      <th><input type="checkbox" ng-click="selectAll()"></th>
      <th>Title</th>
    </tr>
  </thead>
  <tbody>
    <tr ng-repeat="e in entities">
      <td><input type="checkbox" name="selected" ng-click="updateSelection($event, e.id)"></td>
      <td>{{e.title}}</td>
    </tr>
  </tbody>
</table>

As you can see, it's a <table> where each row can be selected individually with its own checkbox, or all rows can be selected at once with a master checkbox located in the <thead>. Pretty classic UI.

What is the best way to:

  • Select a single row (i.e. when the checkbox is checked, add the id of the selected entity to an internal array, and add a CSS class to the <tr> containing the entity to reflect its selected state)?
  • Select all rows at once? (i.e. do the previously described actions for all rows in the <table>)

My current implementation is to add a custom controller to my directive:

controller: function($scope) {

    // Array of currently selected IDs.
    var selected = $scope.selected = [];

    // Update the selection when a checkbox is clicked.
    $scope.updateSelection = function($event, id) {

        var checkbox = $event.target;
        var action = (checkbox.checked ? 'add' : 'remove');
        if (action == 'add' & selected.indexOf(id) == -1) selected.push(id);
        if (action == 'remove' && selected.indexOf(id) != -1) selected.splice(selected.indexOf(id), 1);

        // Highlight selected row. HOW??
        // $(checkbox).parents('tr').addClass('selected_row', checkbox.checked);
    };

    // Check (or uncheck) all checkboxes.
    $scope.selectAll = function() {
        // Iterate on all checkboxes and call updateSelection() on them??
    };
}

More specifically, I wonder:

  • Does the code above belong in a controller or should it go in a link function?
  • Given that jQuery is not necessarily present (AngularJS doesn't require it), what's the best way to do DOM traversal? Without jQuery, I'm having a hard time just selecting the parent <tr> of a given checkbox, or selecting all checkboxes in the template.
  • Passing $event to updateSelection() doesn't seem very elegant. Isn't there a better way to retrieve the state (checked/unchecked) of an element that was just clicked?

Thank you.

AngularChef
  • 13,797
  • 8
  • 53
  • 69

3 Answers3

122

This is the way I've been doing this sort of stuff. Angular tends to favor declarative manipulation of the dom rather than a imperative one(at least that's the way I've been playing with it).

The markup

<table class="table">
  <thead>
    <tr>
      <th>
        <input type="checkbox" 
          ng-click="selectAll($event)"
          ng-checked="isSelectedAll()">
      </th>
      <th>Title</th>
    </tr>
  </thead>
  <tbody>
    <tr ng-repeat="e in entities" ng-class="getSelectedClass(e)">
      <td>
        <input type="checkbox" name="selected"
          ng-checked="isSelected(e.id)"
          ng-click="updateSelection($event, e.id)">
      </td>
      <td>{{e.title}}</td>
    </tr>
  </tbody>
</table>

And in the controller

var updateSelected = function(action, id) {
  if (action === 'add' && $scope.selected.indexOf(id) === -1) {
    $scope.selected.push(id);
  }
  if (action === 'remove' && $scope.selected.indexOf(id) !== -1) {
    $scope.selected.splice($scope.selected.indexOf(id), 1);
  }
};

$scope.updateSelection = function($event, id) {
  var checkbox = $event.target;
  var action = (checkbox.checked ? 'add' : 'remove');
  updateSelected(action, id);
};

$scope.selectAll = function($event) {
  var checkbox = $event.target;
  var action = (checkbox.checked ? 'add' : 'remove');
  for ( var i = 0; i < $scope.entities.length; i++) {
    var entity = $scope.entities[i];
    updateSelected(action, entity.id);
  }
};

$scope.getSelectedClass = function(entity) {
  return $scope.isSelected(entity.id) ? 'selected' : '';
};

$scope.isSelected = function(id) {
  return $scope.selected.indexOf(id) >= 0;
};

//something extra I couldn't resist adding :)
$scope.isSelectedAll = function() {
  return $scope.selected.length === $scope.entities.length;
};

EDIT: getSelectedClass() expects the entire entity but it was being called with the id of the entity only, which is now corrected

Liviu T.
  • 23,584
  • 10
  • 62
  • 58
  • Thanks, Liviu! That works and it helped. And thanks to you, I've learned about the `ngChecked` directive. (My only regret is that we can't make this code a bit less verbose.) – AngularChef Aug 08 '12 at 21:19
  • 1
    Don't think of it as verbose, think of it in terms of separation of concerns. Your data models should not know about the way it's presented. Remember in the controller there is no mention of tr's or td's. at most it contains the checkbox but that could also be factored out. You could always take your controller and apply it to a second template ;) – Liviu T. Aug 08 '12 at 21:23
  • Thanks for this question and answer. I was curious to know the efficiency implications of this approach so I made this plunkr: http://plnkr.co/edit/T5aZO3s5DzSnbrLELveG I noticed that every time I select one of the items, isSelected is called 6 times (twice for each repeater item). Any idea why this is happening twice for each? Anyone concerned about throwing 100+ repeater items on the page and running it on mobile? Probably not be a problem... – Aaronius Mar 24 '13 at 16:42
  • @Aaronius If you add a breakpoint in the isSelected function and refresh you'll see that it's called before the contents of the directive are parsed and executed. I think since it's a directive that does a replace all the bound functions are called twice – Liviu T. Mar 24 '13 at 17:16
  • Is there a method to know the selected checkboxes only ? – Sana Joseph Aug 06 '13 at 14:53
  • @SanaJoseph try asking separate question. The answer depends on specifics – Liviu T. Aug 06 '13 at 15:36
  • Is the bitwise operator here `if (action == 'add' & $scope.selected.indexOf(id) == -1)` intentional or is it a typo? – M.K. Safi Aug 27 '13 at 12:43
  • @MKSafi I just copied the OP's code. Since the other condition has && it's 100% a typo. I don't use bitwise operator for and :) – Liviu T. Aug 27 '13 at 12:52
  • This can also be an option. Might be less handy in a repeat though. [How to select parent checkbox based on child checkboxes in angular.js?](http://stackoverflow.com/a/12144736/1667461) – escapedcat Feb 20 '14 at 08:21
  • @Liviu T: Really nice solution. One thing that seems strange though is passing the $event to the selectAll() method. Wouldn't it be easier to just pass the 'checkbox.checked' property? It reduces coupling (the method gets the boolean value directly instead of having to dig it out of the event). It would also make the method much easier to reuse, especially if you wanted to call it programmatically somewhere. – pbuchheit Aug 03 '15 at 15:07
  • @pbuchheit it would be more maintainable from one point of view but now the html is containing more logic. I think it's a tradeoff you have to think about when writing templates. Normally templates should just contain references not contains more code since that is not easily testable. – Liviu T. Aug 03 '15 at 15:32
  • Liviu T: Your method would not work for me, presumable, because you left something out. $event did not get passed. – Steve Staple Mar 16 '17 at 11:06
  • @LiviuT. You usage of updateSelection($event.target.checked, e.id) seemed like a good idea to me initially, but it throws and error these days: `Error: [$parse:isecdom] Referencing DOM nodes in Angular expressions is disallowed! Expression: updateSelection($event.target.checked)` – Damian Perez Jun 07 '17 at 16:46
  • @DamianPerez can you create a plunker? – Liviu T. Jun 07 '17 at 21:09
34

I prefer to use the ngModel and ngChange directives when dealing with checkboxes. ngModel allows you to bind the checked/unchecked state of the checkbox to a property on the entity:

<input type="checkbox" ng-model="entity.isChecked">

Whenever the user checks or unchecks the checkbox the entity.isChecked value will change too.

If this is all you need then you don't even need the ngClick or ngChange directives. Since you have the "Check All" checkbox, you obviously need to do more than just set the value of the property when someone checks a checkbox.

When using ngModel with a checkbox, it's best to use ngChange rather than ngClick for handling checked and unchecked events. ngChange is made for just this kind of scenario. It makes use of the ngModelController for data-binding (it adds a listener to the ngModelController's $viewChangeListeners array. The listeners in this array get called after the model value has been set, avoiding this problem).

<input type="checkbox" ng-model="entity.isChecked" ng-change="selectEntity()">

... and in the controller ...

var model = {};
$scope.model = model;

// This property is bound to the checkbox in the table header
model.allItemsSelected = false;

// Fired when an entity in the table is checked
$scope.selectEntity = function () {
    // If any entity is not checked, then uncheck the "allItemsSelected" checkbox
    for (var i = 0; i < model.entities.length; i++) {
        if (!model.entities[i].isChecked) {
            model.allItemsSelected = false;
            return;
        }
    }

    // ... otherwise ensure that the "allItemsSelected" checkbox is checked
    model.allItemsSelected = true;
};

Similarly, the "Check All" checkbox in the header:

<th>
    <input type="checkbox" ng-model="model.allItemsSelected" ng-change="selectAll()">
</th>

... and ...

// Fired when the checkbox in the table header is checked
$scope.selectAll = function () {
    // Loop through all the entities and set their isChecked property
    for (var i = 0; i < model.entities.length; i++) {
        model.entities[i].isChecked = model.allItemsSelected;
    }
};

CSS

What is the best way to... add a CSS class to the <tr> containing the entity to reflect its selected state?

If you use the ngModel approach for the data-binding, all you need to do is add the ngClass directive to the <tr> element to dynamically add or remove the class whenever the entity property changes:

<tr ng-repeat="entity in model.entities" ng-class="{selected: entity.isChecked}">

See the full Plunker here.

Community
  • 1
  • 1
Kevin Aenmey
  • 13,259
  • 5
  • 46
  • 45
  • allItemsSelected flag is set to false in the beginning then how it is set to true when click on the select All check box. can you please explain? – user2514925 Apr 04 '19 at 07:42
11

Liviu's answer was extremely helpful for me. Hope this is not bad form but i made a fiddle that may help someone else out in the future.

Two important pieces that are needed are:

    $scope.entities = [{
    "title": "foo",
    "id": 1
}, {
    "title": "bar",
    "id": 2
}, {
    "title": "baz",
    "id": 3
}];
$scope.selected = [];
VBAHole
  • 1,508
  • 2
  • 24
  • 38
  • 1
    Angular docs have a simpler answer for the check all portion. http://docs.angularjs.org/api/ng.directive:ngChecked. Collecting what is checked is something I am trying to figure out. – Hayden Jul 30 '13 at 15:26