4

Description

I have a small product order system, where a user can add order lines, and on each order line add one or more products. (I realise it's quite unusual for more than one product to be on the same order line, but that's another issue).

The products that can be selected on each line is based on a hierarchy of products. For example:

Example product display

T-Shirts
   V-neck
   Round-neck
   String vest

JSON data

$scope.products = [
{ 
  id: 1, 
  name: 'T Shirts', 
  children: [
    { id: 4, name: 'Round-neck', children: [] },
    { id: 5, name: 'V-neck', children: [] },
    { id: 6, name: 'String vest (exclude)', children: [] }
  ] 
},
{ 
  id: 2, 
  name: 'Jackets', 
  children: [
    { id: 7, name: 'Denim jacket', children: [] },
    { id: 8, name: 'Glitter jacket', children: [] }
  ] 
},
{ 
  id: 3, 
  name: 'Shoes', 
  children: [
    { id: 9, name: 'Oxfords', children: [] },
    { id: 10, name: 'Brogues', children: [] },
    { id: 11, name: 'Trainers (exclude)', children: []}
  ] 
}

];

T-Shirts isn't selectable, but the 3 child products are.

What I'm trying to achieve

What I'd like to be able to do, is have a 'select all' button which automatically adds the three products to the order line.

A secondary requirement, is that when the 'select all' button is pressed, it excludes certain products based on the ID of the product. I've created an 'exclusion' array for this.

I've set up a Plunker to illustrate the shopping cart, and what I'm trying to do.

So far it can:

  • Add / remove order lines
  • Add / remove products
  • Add a 'check' for all products in a section, excluding any that are in the 'exclusions' array

The problem

However, although it adds the check in the input, it doesn't trigger the ng-change on the input:

<table class="striped table">
    <thead>
      <tr>
        <td class="col-md-3"></td>
        <td class="col-md-6"></td>
        <td class="col-md-3"><a ng-click="addLine()" class="btn btn-success">+ Add order line</a></td>
      </tr>
    </thead>
    <tbody>
      <tr ng-repeat="line in orderHeader.lines">
        <td class="col-md-3">

          <ul>
            <li ng-repeat="product in products" id="line_{{ line.no }}_product_{{ product.id }}">

              {{ product.name }} <a ng-click="selectAll(product.id, line.no)" class="btn btn-primary">Select all</a>

              <ul>
               <li ng-repeat="child in product.children">
                 <input type="checkbox" 
                      ng-change="sync(bool, child, line)" 
                      ng-model="bool" 
                      data-category="{{child.id}}" 
                      id="check_{{ line.no }}_product_{{ child.id }}"
                      ng-checked="isChecked(child.id, line)">
              {{ child.name }}
               </li> 
              </ul>

            </li>
          </ul>
        </td>
        <td class="col-md-6">
          <pre style="max-width: 400px">{{ line }}</pre>
        </td>
        <td class="col-md-3">
          <a ng-click="removeLine(line)" class="btn btn-warning">Remove line</a>
        </td>
      </tr>
    </tbody>
  </table>

Javascript

$scope.selectAll = function(product_id, line){

  target = document.getElementById('line_'+line+'_product_'+product_id);

  checkboxes = target.getElementsByTagName('input');

  for (var i = 0; i < checkboxes.length; i++) {
    if (checkboxes[i].type == 'checkbox') {

      category = checkboxes[i].dataset.category;

      if($scope.excluded.indexOf(parseInt(category)) == -1)
      {
        checkboxes[i].checked = true;
        // TODO: Check the checkbox, and set its bool parameter to TRUE     
      }
    }
  }
}

Update with full solution

There were a couple of issues with the above code. Firstly, I was trying to solve the problem by manipulating the DOM which is very much against what Angular tries to achieve.

So the solution was to add a 'checked' property on the products so that I can track if they are contained on the order line, and then the view is updated automatically.

One drawback of this method is that the payload would be significantly larger (unless it is filtered before being sent to the back-end API) as each order line now has data for ALL products, even if they aren't selected.

Also, one point that tripped me up was forgetting that Javascript passes references of objects / arrays, not a new copy.

The solution

Javascript

 var myApp = angular.module('myApp', []);

myApp.controller('CartForm', ['$scope', function($scope) {

  var inventory = [
{ 
  id: 1, 
  name: 'T Shirts',
  checked: false, 
  children: [
    { id: 4, name: 'Round-neck', checked: false, children: [] },
    { id: 5, name: 'V-neck', checked: false, children: [] },
    { id: 6, name: 'String vest (exclude)', checked: false, children: [] }
  ] 
},
{ 
  id: 2, 
  name: 'Jackets',
  checked: false, 
  children: [
    { id: 7, name: 'Denim jacket', checked: false, children: [] },
    { id: 8, name: 'Glitter jacket', checked: false, children: [] }
  ] 
},
{ 
  id: 3, 
  name: 'Shoes', 
  checked: false, 
  children: [
    { id: 9, name: 'Oxfords', checked: false, children: [] },
    { id: 10, name: 'Brogues', checked: false, children: [] },
    { id: 11, name: 'Trainers (exclude)', checked: false, children: []}
  ] 
}
   ];

  $scope.debug_mode = false;


  var products = angular.copy(inventory);

  $scope.orderHeader = {
order_no: 1,
total: 0,
lines: [
  {
    no: 1,
    products: products,
    total: 0,
    quantity: 0
  }
]
  };


  $scope.excluded = [6, 11];

   $scope.addLine = function() {

 var products = angular.copy(inventory);

  $scope.orderHeader.lines.push({
      no: $scope.orderHeader.lines.length + 1,
      products: products,
      quantity: 1,
      total: 0
  });

  $scope.loading = false;

}


    $scope.removeLine = function(index) {
  $scope.orderHeader.lines.splice(index, 1);
}  


$scope.selectAll = function(product){

  angular.forEach(product.children, function(item){
    if($scope.excluded.indexOf(parseInt(item.id)) == -1) {
        item.checked=true;
    }
  });

}

$scope.removeAll = function(product){

  angular.forEach(product.children, function(item){
    item.checked=false;
  });

}

$scope.toggleDebugMode = function(){
  $scope.debug_mode = ($scope.debug_mode ? false : true);
}


}]);

Click here to see the Plunker

Amo
  • 2,884
  • 5
  • 24
  • 46

2 Answers2

2

You are really over complicating things first by not taking advantage of passing objects and arrays into your controller functions and also by using the DOM and not your data models to try to update states

Consider this simplification that adds a checked property to each product via ng-model

<!-- checkboxes -->
<li ng-repeat="child in product.children">
       <input ng-model="child.checked"  >
</li>

If it's not practical to add properties to the items themselves, you can always keep another array for the checked properties that would have matching indexes with the child arrays. Use $index in ng-repeat for that

And passing whole objects into selectAll()

<a ng-click="selectAll(product,line)">

Which allows in controller to do:

$scope.selectAll = function(product, line){      
  angular.forEach(product.children, function(item){
       item.checked=true;
  });
  line.products=product.children;      
}

With angular you need to always think of manipulating your data models first, and let angular manage the DOM

Strongly suggest reading : "Thinking in AngularJS" if I have a jQuery background?

DEMO

Community
  • 1
  • 1
charlietfl
  • 170,828
  • 13
  • 121
  • 150
  • if you click "Select All" on any button, then uncheck any item, that item is removed from screen. – André Werlang Apr 29 '15 at 18:47
  • @Werlang I am not attempting to rewrite the whole app. I only modified the functionality mentioned in answer and likely introduced bugs in the rest of it. Feel free to modify the rest but it means basically starting over – charlietfl Apr 29 '15 at 18:51
  • 1
    @Werlang was also trying to teach a little bit about changing way of thinking which is a lot of the point of SO, not just providing code that does everything – charlietfl Apr 29 '15 at 18:56
  • I'm almost there. I can get the select all / none working, however it doesn't work when there is more than one order line. Selecting in the first line adds the product in all other lines. I've updated the Plunker here: http://plnkr.co/edit/7t2bwjJaCs0gjXz80FQt?p=preview – Amo Apr 30 '15 at 08:58
  • The UI seems to work. Feel free to accept answer if it helped you. If there are more issues I don't see what they are – charlietfl Apr 30 '15 at 10:12
  • To see what I mean; add a second order line. Click any product in the first line and you'll see it gets selected in the second order line too. The 'checked' parameter isn't bound to the particular instance of the object from the line despite being instantiated from within that line. – Amo Apr 30 '15 at 10:54
  • That's because `inventory` is the same array in both. Make a copy of it instead. Why would you show all the same items again anyway? Also your order numbers can get duplicated if user removes one. No need for order number ... that would be an ID from server – charlietfl Apr 30 '15 at 13:16
  • I forgot that arrays and objects are passed by reference instead of copied in this instance. Thanks. Regarding the IDs, my example code is just a very simplified example for the purposes of debugging this issue so it's rather crude. All sorted now. Your comments pointed me in the right direction so I'll accept this as the answer. – Amo Apr 30 '15 at 14:13
  • bingo...is a bit of a new way of thinking but if you understand reference then you won't have a lot of troubles getting a better grasp of angular – charlietfl Apr 30 '15 at 14:16
0

Why ng-change isn't fired when the checkbox is checked programatically?

It happens because

  if($scope.excluded.indexOf(parseInt(category)) == -1)
  {
    checkboxes[i].checked = true;
    // TODO: Check the checkbox, and set its bool parameter to TRUE     
  }

only affects the view (DOM). ng-change works alongside ngModel, which can't be aware that the checkbox really changed visually.

I suggest you to refer to the solution I provided at How can I get angular.js checkboxes with select/unselect all functionality and indeterminate values?, works with any model structure you have (some may call this the Angular way).

Community
  • 1
  • 1
André Werlang
  • 5,839
  • 1
  • 35
  • 49