Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Pro/con of using Angular directives for complex form validation/ GUI manipulation

Tags:

angularjs

I am building a new SPA front end to replace an existing enterprise's legacy hodgepodge of systems that are outdated and in need of updating. I am new to angular, and wanted to see if the community could give me some perspective. I'll state my problem, and then ask my question.

I have to generate several series of check boxes based on data from a .js include, with data like this:

$scope.fieldMappings.investmentObjectiveMap = [
  {'id':"CAPITAL PRESERVATION", 'name':"Capital Preservation"},
  {'id':"STABLE", 'name':"Moderate"},
  {'id':"BALANCED", 'name':"Moderate Growth"},
   // etc
  {'id':"NONE", 'name':"None"}
];

The checkboxes are created using an ng-repeat, like this:

    <div ng-repeat="investmentObjective in fieldMappings.investmentObjectiveMap">
     ...
    </div>

However, I needed the values represented by the checkboxes to map to a different model (not just 2-way-bound to the fieldmappings object). To accomplish this, I created a directive, which accepts a destination array destarray which is eventually mapped to the model. I also know I need to handle some very specific gui controls, such as unchecking "None" if anything else gets checked, or checking "None" if everything else gets unchecked. Also, "None" won't be an option in every group of checkboxes, so the directive needs to be generic enough to accept a validation function that can fiddle with the checked state of the checkbox group's inputs based on what's already clicked, but smart enough not to break if there is no option called "NONE". I started to do that by adding an ng-click which invoked a function in the controller, but in looking around stack overflow, I read people saying that its bad to put DOM manipulation code inside your controller - it should go in directives. So do I need another directive?

So far: (html):

      <input my-checkbox-group
              type="checkbox"
              fieldobj="investmentObjective"
              ng-click="validationfunc()"
              validationfunc="clearOnNone()"
              destarray="investor.investmentObjective" />

Directive code:

.directive("myCheckboxGroup", function () {
  return {
    restrict: "A",
    scope: {
      destarray:      "=",  // the source of all the checkbox values
      fieldobj:       "=",  // the array the values came from
      validationfunc: "&"   // the function to be called for validation (optional)
    },
    link: function (scope, elem, attrs) {
      if (scope.destarray.indexOf(scope.fieldobj.id) !== -1) {
        elem[0].checked = true;
      }
      elem.bind('click', function () {
        var index = scope.destarray.indexOf(scope.fieldobj.id);
        if (elem[0].checked) {
          if (index === -1) {
            scope.destarray.push(scope.fieldobj.id);
          }
        }
        else {
          if (index !== -1) {
            scope.destarray.splice(index, 1);
          }
        }
      });
    }
  };
})

.js controller snippet:

.controller( 'SuitabilityCtrl', ['$scope', function ( $scope ) {
  $scope.clearOnNone = function() {
    // naughty jQuery DOM manipulation code that
    // looks at checkboxes and checks/unchecks as needed
  };

The above code is done and works fine, except the naughty jquery code in clearOnNone(), which is why I wrote this question.

And here is my question: after ALL this, I think to myself - I could be done already if I just manually handled all this GUI logic and validation junk with jQuery written in my controller. At what point does it become foolish to write these complicated directives that future developers will have to puzzle over more than if I had just written jQuery code that 99% of us would understand with a glance? How do other developers draw the line?

I see this all over stack overflow. For example, this question seems like it could be answered with a dozen lines of straightforward jQuery, yet he has opted to do it the angular way, with a directive and a partial... it seems like a lot of work for a simple problem.

I don't want this question to violate the rules, so specifically, I suppose I would like to know: how SHOULD I be writing the code that checks whether "None" has been selected (if it exists as an option in this group of checkboxes), and then check/uncheck the other boxes accordingly? A more complex directive? I can't believe I'm the only developer that is having to implement code that is more complex than needed just to satisfy an opinionated framework. Is there another util library I need to be using?

like image 871
tengen Avatar asked Oct 04 '13 20:10

tengen


1 Answers

I posted this on Programmers.StackExchange.com as per Jim's suggestion. In the meantime, I settled on a solution for handling all the tricky DOM manipulations.

I tried it both ways - handling the DOM event in the controller, and handling it via a directive:

(Via Controller) - .js code:

 $scope.clearOnNone = function(groupName, $event) {
    var chkboxArr = $('input[name^=' + groupName + ']'),
        nonNoneValChecked = false,
        targetElem = null,
        labelText = "";

    // get the target of the click event by looking at the <label> sibling's text
    targetElem = event.target.nextElementSibling.textContent.trim();

    // if target was the None option, uncheck all others
    if (targetElem === "None") {
      chkboxArr.each(function() {
        labelText = this.nextElementSibling.textContent.trim();

        if (labelText !== "None") {
          this.checked = false;
        }
      });
    }
    // if the target was anything BUT the None option, uncheck None
    else {
      chkboxArr.each(function() {
        labelText = this.nextElementSibling.textContent.trim();

        if (labelText === "None") {
          this.checked = false;
        }
      });
    }
  };

(Via Controller) - html code:

      <div ng-repeat="investmentObjective in fieldMappings.secondaryInvestmentObjectiveMap">
        <input checkbox-group
                type="checkbox"
                name="secondaryInvestmentObjective"
                ng-click="validationfunc('secondaryInvestmentObjective', $event)"
                validationfunc="clearOnNone('secondaryInvestmentObjective', $event)"
                fieldobj="investmentObjective"
                destarray="suitabilityHolder.suitability.secondaryInvestmentObjective" />
        <label class="checkbox-label"
                popover-title="{{investmentObjective.name}}"
                popover="{{investmentObjective.help}}"
                popover-trigger="mouseenter">{{investmentObjective.name}}
        </label>
      </div>

(Via Controller) - directive code:

.directive("checkboxGroup", function () {
  return {
    restrict: "A",
    scope: {
      destarray:      "=",  // the source of all the checkbox values
      fieldobj:       "=",  // the array the values came from
      validationfunc: "&"   // the function to be called for validation (optional)
    },
    link: function (scope, elem, attrs) {
      if (scope.destarray.indexOf(scope.fieldobj.id) !== -1) {
        elem[0].checked = true;
      }
      elem.bind('click', function () {
        var index = scope.destarray.indexOf(scope.fieldobj.id);
        if (elem[0].checked) {
          if (index === -1) {
            scope.destarray.push(scope.fieldobj.id);
          }
        }
        else {
          if (index !== -1) {
            scope.destarray.splice(index, 1);
          }
        }
      });
    }
  };
})

I then decided that I hated the event.target.nextElementSibling.textContent.trim() lines... I feel like I should be double checking that all of those methods exist, or using a try/catch. So I rewrote the directive to include the logic from the controller:

(via directive) - html code:

      <div ng-repeat="otherInvestment in fieldMappings.otherInvestmentsMap">
        <input type="checkbox"
                checkbox-group
                groupname="otherInvestment"
                labelvalue="{{otherInvestment.name}}"
                fieldobj="otherInvestment"
                destarray="suitabilityHolder.suitability.otherInvestment" />
        <label class="checkbox-label"
                popover-title="{{otherInvestment.name}}"
                popover="{{otherInvestment.help}}"
                popover-trigger="mouseenter">{{otherInvestment.name}}
        </label>
      </div>

(via directive) - directive code:

.directive("checkboxGroup", function () {
  return {
    restrict: "A",
    scope: {
      destarray:      "=",  // the source of all the checkbox values
      fieldobj:       "=",  // the array the values came from
      groupname:      "@",  // the logical name of the group of checkboxes
      labelvalue:     "@"   // the value that corresponds to this checkbox
    },
    link: function (scope, elem, attrs) {
      // Determine initial checked boxes
      // if the fieldobj.id exists in the destarray, check this checkbox
      if (scope.destarray.indexOf(scope.fieldobj.id) !== -1) {
        elem[0].checked = true;
      }

      // Update array on click
      elem.bind('click', function () {
        // store the index where the fieldobj.id exists in the destarray
        var index = scope.destarray.indexOf(scope.fieldobj.id),
            // get the array of checkboxes that form this checkbox group
            chkboxArr = $('input[groupname^=' + scope.groupname + ']');

        // Add if checked
        if (elem[0].checked) {
          if (scope.labelvalue === "None") {
            // loop through checkboxes and uncheck all the ones that are not "None"
            chkboxArr.each(function() {
              // have to noodle through the checkbox DOM element to get at its attribute list
              // - is there a cleaner way?
              var tmpLabelValue = this.attributes.labelvalue.nodeValue.trim();
              if (tmpLabelValue !== "None") {
                this.checked = false;
              }
            });
          }
          // if the target was anything BUT the None option, uncheck None
          else {
            chkboxArr.each(function() {
              var tmpLabelValue = this.attributes.labelvalue.nodeValue.trim();

              if (tmpLabelValue === "None") {
                this.checked = false;
              }
            });
          }

          if (index === -1) {
            // add the id to the end of the dest array
            // **will not maintain original order if several are unchecked then rechecked**
            scope.destarray.push(scope.fieldobj.id);
          }
        }

        // Remove if unchecked
        else {
          if (index !== -1) {
            scope.destarray.splice(index, 1);
          }
        }
      });
    }
  };
})

In retrospect, I suppose I prefer to house all the code in a directive, even though I think it's less intuitive and more complex than tossing all the handling in the controller via jQuery. It cuts out the clearOnNone() function from the controller, meaning that all the code that deals with this functionality it in the html markup and the directive.

I am not a fan of code like this.attributes.labelvalue.nodeValue.trim(), which I still ended up with in my directive. For scenarios like mine, where the business unit has certain requirements that (no other way to put it) are tedious and cumbersome, I don't know that there is really a 'clean' way to code it all up.

like image 107
tengen Avatar answered Sep 28 '22 02:09

tengen