5

So this is a javascript conversion from jQuery app into Angular app. The current jQuery app works but is needed to make into a real app using the Angular framework.

The logic behind the whole app is to select categories and filter OUT and get specific results based on the filter buttons. So lets say you want only to see results that include only where filter 1 AND filter 2 are together, but not (filter1, filter2, and filter1+filter2). see the jquery version: demo

$(document).ready(function(){
    $('.filter-selector').click(function(){

        /* Filter 1 Categories */
        if($(this).attr("value")==".filter1_group1"){
            $(".filter1_group1-show").toggle();
            $(this).toggleClass('active');
        }
        if($(this).attr("value")==".filter1_group2"){
            $(".filter1_group2-show").toggle();
            $(this).toggleClass('active');
        }
    });
});

Now I need to convert that javascript magic over to angular, keep the buttons in a toggle stat and show results on the second view. It will essentially be an Angular SPA with 2 views: 1 for filters and 1 for results. Previous app was using jQuery toggle class function, but there is no built in function for Angular in this case. All the examples for toggle button for Angular have only 1 toggle button that hides/shows divs. And other example buttons only show or hide divs separately and are not toggle buttons. And how do I turn filter results into service return and then inject that into View 2 as results and show them?

Need some direction from Angular gods here...

UPDATE 1: thanx to Shaun Scovil, the Angular way of creating this filter groups was found. However the filter group works well on a single page but not in 2 view SPA app: plunkr The filters will break after switching between filters and cases a few times.

UPDATE 2: thanx to Shaun Scovil once more, the filters/cases toggle buttons work now going from page view to page view back to any number of views: plunkr

3
  • Suggest you look into some filtering tutorials. Also tutorial on angular docs site has quite a bit of what you need to use in it. Commented Jan 28, 2016 at 20:32
  • Are there any angular filtering tutorials with toggle buttons? Commented Jan 28, 2016 at 20:50
  • The buttons are not hard to wire up to scope model that would be used in those filters. Get the general angular filtering concepts figured out. Until you get a good understanding of how the data model drives the view it is very hard to tell you what to do to convert Commented Jan 28, 2016 at 20:59

2 Answers 2

7

Based on your example app and description, here is how I would describe what you need in Angular terms:

  • a controller for your filter toggles view
  • a controller for your cases view
  • a service to store toggled filters
  • a directive for your filter toggle buttons
  • a filter to reduce the list of cases by toggled filters

Working example: JSFiddle (UPDATED to work with ngRoute)

Controllers

The two controllers should serve as view models, providing some well-formed data that can be used in their respective view templates. For example:

angular.module('myApp')
  .controller('FilterToggleController', FilterToggleController)
  .controller('CasesController', CasesController)
;

function FilterToggleController() {
  var vm = this;
  vm.filterGroups = {
    1: [1,2],
    2: [1,2]
  };
}

function CasesController() {
  var vm = this;
  vm.cases = [
    {label:'Case 1,2', filters:[{group:1, filter:1}, {group:1, filter: 2}]},
    {label:'Case 1',   filters:[{group:1, filter:1}]},
    {label:'Case 2',   filters:[{group:1, filter:2}]},
    {label:'Case 1,3', filters:[{group:1, filter:1}, {group:2, filter:1}]},
    {label:'Case 4',   filters:[{group:2, filter:2}]}
  ];
}

Service

The purpose of an Angular service is to share data or functionality among controllers, directives, filters and other services. Your service is a data store for the selected filters, so I would use a $cacheFactory cache under the hood. For example:

angular.module('myApp')
  .factory('$filterCache', filterCacheFactory)
;

function filterCacheFactory($cacheFactory) {
  var cache = $cacheFactory('filterCache');
  var $filterCache = {};

  $filterCache.has = function(group, filter) {
    return cache.get(concat(group, filter)) === true;
  };

  $filterCache.put = function(group, filter) {
    cache.put(concat(group, filter), true);
  }

  $filterCache.remove = function(group, filter) {
    cache.remove(concat(group, filter));
  }

  $filterCache.count = function() {
    return cache.info().size;
  }

  function concat(group, filter) {
    return group + ':' + filter;
  }

  return $filterCache;
}

Directive

A directive adds functionality to an HTML element. In your case, I would create a directive with a 'click' event handler that can be added as an attribute to a button or any other element. Our $filterCache service could be used by the event handler to keep track of the group/filter combination that the button represents. For example:

angular.module('myApp')
  .directive('toggleFilter', toggleFilterDirective)
;

function toggleFilterDirective($filterCache) {
  return function(scope, iElement, iAttrs) {
    var toggled = false;

    iElement.on('click', function() {
      var group = scope.$eval(iAttrs.group);
      var filter = scope.$eval(iAttrs.filter);

      toggled = !toggled;

      if (toggled) {
        $filterCache.put(group, filter);
        iElement.addClass('toggled');
      } else {
        $filterCache.remove(group, filter);
        iElement.removeClass('toggled');
      }

      scope.$apply();
    });
  };
}

Filter

The purpose of the filter is to take the array of case objects defined in CasesController and reduce them based on the filters stored in our $filterCache service. It will reduce the list to an empty array if no filters are toggled. For example:

angular.module('myApp')
  .filter('filterCases', filterCasesFactory)
;

function filterCasesFactory($filterCache) {
  return function(items) {
    var filteredItems = [];
    var filterCount = $filterCache.count();

    if (filterCount) {
      angular.forEach(items, function(item) {
        if (angular.isArray(item.filters) && item.filters.length >= filterCount) {
          for (var matches = 0, i = 0; i < item.filters.length; i++) {
            var group = item.filters[i].group;
            var filter = item.filters[i].filter;

            if ($filterCache.has(group, filter))
              matches++;

            if (matches === filterCount) {
              filteredItems.push(item);
              break;
            }
          }
        }
      });
    }

    return filteredItems;
  };
}

Template

Finally, the HTML template ties it all together. Here is an example of how that would look using all of the other pieces we've built:

<!-- Filter Toggles View -->
<div ng-controller="FilterToggleController as vm">
  <div ng-repeat="(group, filters) in vm.filterGroups">
    <h2>
      Group {{group}}
    </h2>
    <div ng-repeat="filter in filters">
      <button toggle-filter group="group" filter="filter">
        Filter {{filter}}
      </button>
    </div>
  </div>
</div>

<!-- Cases View -->
<div ng-controller="CasesController as vm">
  <h2>
    Your Cases
  </h2>
  <ol>
    <li ng-repeat="case in vm.cases | filterCases">
      {{case.label}}
    </li>
  </ol>
</div>

UPDATE

Based on the comments, I updated the JSFiddle example to work with ngRoute by making the following changes to the toggleFilterDirective:

function toggleFilterDirective($filterCache) {
  return function(scope, iElement, iAttrs) {
    var group, filter, toggled;
    sync();
    update();
    iElement.on('click', onClick);
    scope.$on('$destroy', offClick);

    function onClick() {
      sync();
      toggle();
      update();
      scope.$apply();
    }

    function offClick() {
      iElement.off('click', onClick);
    }

    function sync() {
      group = scope.$eval(iAttrs.group);
      filter = scope.$eval(iAttrs.filter);
      toggled = $filterCache.has(group, filter);
    }

    function toggle() {
      toggled = !toggled;
      if (toggled) {
        $filterCache.put(group, filter);
      } else {
        $filterCache.remove(group, filter);
      }
    }

    function update() {
      if (toggled) {
        iElement.addClass('toggled');
      } else {
        iElement.removeClass('toggled');
      }
    }
  };
}

Here is a link to the original example: JSFiddle

Sign up to request clarification or add additional context in comments.

6 Comments

Wow Shaun, you da man. I did not expect such a comprehensive answer to my issue. I have been trying to learn Angular for months now and I dont think im close to that kind of javascript/Angular expertise. Since I am still learning more about the language, how did you come to the conclusion of 2 controllers, directive, filter and service? I was under the understanding of just having 2 controllers and factory will do...
Glad I could help. It really comes down to using the right tool for the job. Controllers in Angular serve the purpose of exposing data and functionality to templates, so they are really more like view models. Services are injectable and provide functionality to controllers, directives, filters, etc. If you have an array that you want to reduce (or filter) based on some user input, a filter is the way to go. And if you need to interact w/ DOM elements, like when adding event listeners, creating a custom directive is the appropriate strategy.
The code does work as intended on single page, however even with the 2 controllers, the filter group and cases mechanic collapses when switching between filters/cases views in SPA. When hitting first filter, then going to cases view, see the results, go back to first view, hitting another filter(s), then going to cases view, you can see the incorrect results. Should the filter toggle states as "clicked" be retained? I am not sure why the view model logic collapses after first go-around. Is it a simple reformatting of directive? or filter?
@AivoK I see what you are saying. Here is a working example that uses ngRoute: jsfiddle.net/sscovil/syh8qchf
Again Shaun with the expanded solution! So it was directive that needed to remove click eventlistener from going page to page? Since I am still learning Angular, is learning and experimenting with custom Directives the way to go as I see that it was the directive that made the difference in this app? I def owe a round of beers for these solutions! Thanx
|
-1
<!DOCTYPE html>
<html>
<head>
    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.9/angular.min.js"></script>
    <script>
        var app = angular.module('App', []);

        app.service('serviceFilters', function() {
            var filters = [
                    {
                        name: 'Filter 1',
                        groups: [
                            {
                                name: 'Group 1',
                                selected: false
                            },
                            {
                                name: 'Group 2',
                                selected: false
                            }
                        ]
                    },
                    {
                        name: 'Filter 2',
                        groups: [
                            {
                                name: 'Group 1',
                                selected: false
                            },
                            {
                                name: 'Group 2',
                                selected: false
                            }
                        ]
                    }
                ],
                getFilters = function () {
                    return filters;
                },
                isCase12Selected = function () {
                    return filters[0].groups[0].selected && filters[0].groups[1].selected && !filters[1].groups[0].selected && !filters[1].groups[1].selected;
                },
                isCase1Selected = function () {
                    return filters[0].groups[0].selected && !filters[0].groups[1].selected && !filters[1].groups[0].selected && !filters[1].groups[1].selected;
                },
                isCase2Selected = function () {
                    return !filters[0].groups[0].selected && filters[0].groups[1].selected && !filters[1].groups[0].selected && !filters[1].groups[1].selected;
                },
                isCase13Selected = function () {
                    return filters[0].groups[0].selected && !filters[0].groups[1].selected && filters[1].groups[0].selected && filters[1].groups[1].selected;
                },
                isCase4Selected = function () {
                    return !filters[0].groups[0].selected && !filters[0].groups[1].selected && !filters[1].groups[0].selected && filters[1].groups[1].selected;
                };

                return {
                    getFilters: getFilters,
                    isCase12Selected: isCase12Selected,
                    isCase1Selected: isCase1Selected,
                    isCase2Selected: isCase2Selected,
                    isCase13Selected: isCase13Selected,
                    isCase4Selected: isCase4Selected
                };
        });

        app.filter('selectedGroups', function() {
            return function(groups) {
                return groups.filter(function (group) {
                    return group.selected;
                });
            };
        });

        app.directive(
            'viewApplication',
            [
                'serviceFilters',
                function (serviceFilters) {
                    'use strict';

                    return {
                        restrict: 'E',
                        template:
                            '<div>' +
                                '<view-filters></view-filters>' +
                                '<view-selected></view-selected>' +
                                '<view-cases></view-selected>' +
                            '</div>',
                        controller: ['$scope', function ($scope) {
                            $scope.serviceFilters = serviceFilters;
                        }]
                    };
                }
            ]
        );

        app.directive(
            'viewFilters',
            [
                'serviceFilters',
                function (serviceFilters) {
                    'use strict';

                    return {
                        restrict: 'E',
                        scope: {},
                        template:
                            '<div>' +
                                '<h1>Filters</h1>' +
                                '<div ng-repeat="filter in serviceFilters.getFilters()">' +
                                    '<h2>{{::filter.name}}</h2>' +
                                    '<div ng-repeat="group in filter.groups">' +
                                        '<span>{{::group.name}}&nbsp;</span>' +
                                        '<button ng-click="group.selected=!group.selected">{{group.selected ? \'Unselect\' : \'Select\'}}</button>' +
                                    '</div>' +
                               '</div>' +
                            '</div>',
                        controller: ['$scope', function ($scope) {
                            $scope.serviceFilters = serviceFilters;
                        }]
                    };
                }
            ]
        );

        app.directive(
            'viewSelected',
            [
                'serviceFilters',
                function (serviceFilters) {
                    'use strict';

                    return {
                        restrict: 'E',
                        scope: {},
                        template:
                            '<div>' +
                                '<h1>Selected</h1>' +
                                '<div ng-repeat="filter in serviceFilters.getFilters()">' +
                                    '<div ng-repeat="group in filter.groups | selectedGroups">' +
                                        '{{filter.name}} | {{group.name}}' +
                                    '</div>' +
                                '</div>' +
                            '</div>',
                        controller: ['$scope', function ($scope) {
                            $scope.serviceFilters = serviceFilters;
                        }]
                    };
                }
            ]
        );

        app.directive(
            'viewCases',
            [
                'serviceFilters',
                function (serviceFilters) {
                    'use strict';

                    return {
                        restrict: 'E',
                        scope: {
                            filters: '='
                        },
                        template:
                            '<div>' +
                                '<h1>Cases</h1>' +
                                '<span ng-if="serviceFilters.isCase12Selected()">Case 1,2</span>' +
                                '<span ng-if="serviceFilters.isCase1Selected()">Case 1</span>' +
                                '<span ng-if="serviceFilters.isCase2Selected()">Case 2</span>' +
                                '<span ng-if="serviceFilters.isCase13Selected()">Case 1,3</span>' +
                                '<span ng-if="serviceFilters.isCase14Selected()">Case 4</span>' +
                            '</div>',
                        controller: ['$scope', function ($scope) {
                            $scope.serviceFilters = serviceFilters;
                        }]
                    };
                }
            ]
        );
    </script>
</head>
<body ng-app="App">
    <view-application></view-application>
</body>
</html>

3 Comments

Hmm. Are the filters array part of a controller? How can I use toggle button with the arrays and objects if Angular toggle buttons work with boolean values?
Yes, you are right. Filter as model (service) - better. And i forgot about "cases" part. See updated code.
This is not scalable. Every time you add a filter, the number of serviceFilters.isCaseNNSelected functions needed would increase exponentially.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.