4

In my AngularJS app, I have a template that contains a <textarea> and a submit button. A <div> (which could just as easily be a <form>) wraps both of those elements and has a controller. The controller utilizes a $resource service to POST the value from the <textarea> to a REST API when the submit button is clicked.

Template:

<div ng-controller="MyController as vm">
  <textarea ng-model="vm.text" rows="5"></textarea>
  <button ng-click="vm.save()">Save</button>
</div>

Controller:

angular.module('myApp')
  .controller('MyController', MyController);

MyController.$inject = ['myRestApiResource'];

function MyController(myRestApiResource) {
  var vm = this;
  vm.save = function() {
    var params = [];
    var postData = {text: vm.text};
    myRestApiResource.save(params, postData)
      .then(function(res) { /* success handler */ })
      .catch(function(res) { /* error handler */ });
  };
}

When the resource save() method's $promise is rejected, the response data (in some cases) contains an error.position property that refers to the string index where a syntax error was detected in vm.text.

In those cases, I focus on the <textarea> element and set the selection range from the error.position to the next non-word character. I have a function for this, and it all works fine.

My question is:

Should the function call that sets the selection range on the <textarea> (after the promise is rejected) be performed in...

  1. the controller
  2. a custom directive added to the textarea
  3. somewhere else

...and why?

This is a question about separation of concerns.

The controller seems like the most sensible place, but it is my understanding that controllers should not perform DOM manipulation...and this would require the use of the $element service (or jQuery) for doing just that.

A custom directive would need to subscribe to an event broadcast by the controller, and then set its own focus and selection range. This separates the concerns, but according to Angular Best Practices $broadcast and $on should only be used for "events that are relevant globally across the entire app".

I suppose the controller and directive could communicate via another service, but that just seems like over-engineering -- something I am notorious for and trying hard to avoid here! :-)

Thanks in advance!


Solution

For completeness, here is the custom directive I wrote based on @kristin-fritsch 's answer:

Directive

angular.module('myApp')
  .directive('selectionRange', selectionRangeDirective);

function selectionRangeDirective() {
  return {
    restrict: 'A',
    scope: {
      selectionRange: '='
    },
    link: function(scope, iElement) {
      var element = iElement[0];
      if (element.setSelectionRange && typeof element.setSelectionRange === 'function') {
        scope.$watch('selectionRange', function(range) {
          if (range && typeof range.start === 'number' && range.start > -1 && range.start < iElement.val().length) {
            element.focus();
            element.setSelectionRange(range.start, range.end);
          }
        });
      }
    }
  };
}

To implement it, I added the directive attribute to my textarea:

<textarea ng-model="vm.text" rows="5" selection-range="vm.selection"></textarea>

...and set the value of vm.selection to an object with start and end properties in my controller, when the promise is rejected.

myRestApiResource.save(params, postData)
  .then(function(res) { /* success handler */ })
  .catch(function(res) {
    var error = res.data.error;
    if (error && error.position) {
      var start = error.position;
      var end = start + vm.text.substr(start).search(/(\W|$)/);
      vm.selection = {start: start, end: end};
    }
  });
Shaun Scovil
  • 3,905
  • 5
  • 39
  • 58

1 Answers1

5

I suggest you add a directive to the textarea.

<div ng-controller="MyController as vm">
  <textarea ng-model="vm.text" data-error-position="errorPosition" rows="5"></textarea>
  <button ng-click="vm.save()">Save</button>
</div>

Then you could set the position when the $promise got rejected like this

$scope.errorPosition = 50;

And handle the DOM manipulation in the errorPosition - directive.