select2, ng-model and angular

Noy picture Noy · Apr 26, 2015 · Viewed 10.5k times · Source

Using jquery-select2 (not ui-select) and angular, I'm trying to set the value to the ng-model.

I've tried using $watch and ng-change, but none seem to fire after selecting an item with select2.

Unfortunately, I am using a purchased template and cannot use angular-ui.

HTML:

<input type="hidden" class="form-control select2remote input-medium"
    ng-model="contact.person.id"
    value="{{ contact.person.id }}"
    data-display-value="{{ contact.person.name }}"
    data-remote-search-url="api_post_person_search"
    data-remote-load-url="api_get_person"
    ng-change="updatePerson(contact, contact.person)">

ClientController:

$scope.updatePerson = function (contact, person) {
    console.log('ng change');
    console.log(contact);
    console.log(person);
} // not firing

$scope.$watch("client", function () {
    console.log($scope.client);
}, true); // not firing either

JQuery integration:

var handleSelect2RemoteSelection = function () {
    if ($().select2) {
        var $elements = $('input[type=hidden].select2remote');
        $elements.each(function(){
            var $this = $(this);
            if ($this.data('remote-search-url') && $this.data('remote-load-url')) {
                $this.select2({
                    placeholder: "Select",
                    allowClear: true,
                    minimumInputLength: 1,
                    ajax: { // instead of writing the function to execute the request we use Select2's convenient helper
                        url: Routing.generate($this.data('remote-search-url'), {'_format': 'json'}),
                        type: 'post',
                        dataType: 'json',
                        delay: 250,
                        data: function (term, page) {
                            return {
                                query: term, // search term
                            };
                        },
                        results: function (data, page) { // parse the results into the format expected by Select2.
                            return {
                                results: $.map(data, function (datum) {
                                    var result = {
                                        'id': datum.id,
                                        'text': datum.name
                                    };
                                    for (var prop in datum) {
                                        if (datum.hasOwnProperty(prop)) {
                                            result['data-' + prop] = datum[prop];
                                        }
                                    }
                                    return result;
                                })
                            }
                        }
                    },
                    initSelection: function (element, callback) {
                        // the input tag has a value attribute preloaded that points to a preselected movie's id
                        // this function resolves that id attribute to an object that select2 can render
                        // using its formatResult renderer - that way the movie name is shown preselected
                        var id = $(element).val(),
                            displayValue = $(element).data('display-value');
                        if (id && id !== "") {
                            if (displayValue && displayValue !== "") {
                                callback({'id': $(element).val(), 'text': $(element).data('display-value')});
                            } else {
                                $.ajax(Routing.generate($this.data('remote-load-url'), {'id': id, '_format': 'json'}), {
                                    dataType: "json"
                                }).done(function (data) {
                                    callback({'id': data.id, 'text': data.name});
                                });
                            }
                        }
                    },
                });
            }
        });
    }
};

Any advice would be greatly appreciated! :)

UPDATE

I've managed to put together a plunk which seems to similarly reproduce the problem - it now appears as if the ng-watch and the $watch events are fired only when first changing the value. Nevertheless, in my code (and when adding further complexity like dynamically adding and removing from the collection), it doesn't even seem to fire once.

Again, pointers in the right direction (or in any direction really) would be greatly appreciated!

Answer

Joe Enzminger picture Joe Enzminger · Apr 28, 2015

There are a number of issues with your example. I'm not sure I am going to be able to provide an "answer", but hopefully the following suggestions and explanations will help you out.

First, you are "mixing" jQuery and Angular. In general, this really doesn't work. For example:

In script.js, you run

$(document).ready(function() {
  var $elements = $('input[type=hidden].select2remote');
  $elements.each(function() {
     //...
  });
});

This code is going to run once, when the DOM is initially ready. It will select hidden input elements with the select2remote class that are currently in the DOM and initialized the select2 plugin on them.

The problem is that any new input[type=hidden].select2remote elements added after this function is run will not be initialized at all. This would happen if you are loading data asynchronously and populating an ng-repeat, for example.

The fix is to move the select2 initialization code to a directive, and place this directive on each input element. Abridged, this directive might look like:

.directive('select2', function() {
    return {
        restrict: 'A',
        require: 'ngModel',
        link: function(scope, element, attr, ngModel) {

            //$this becomes element

            element.select2({
                //options removed for clarity
            });

            element.on('change', function() {
                 console.log('on change event');
                 var val = $(this).value;
                 scope.$apply(function(){
                     //will cause the ng-model to be updated.
                     ngModel.setViewValue(val);
                 });
            });
            ngModel.$render = function() {
                 //if this is called, the model was changed outside of select, and we need to set the value
                //not sure what the select2 api is, but something like:
                element.value = ngModel.$viewValue;
            }

        }
    }
});

I apologize that I'm not familiar enough with select2 to know the API for getting and setting the current value of the control. If you provide that to me in a comment I can modify the example.

Your markup would change to:

<input select2 type="hidden" class="form-control select2remote input-medium"
    ng-model="contact.person.id"
    value="{{ contact.person.id }}"
    data-display-value="{{ contact.person.name }}"
    data-remote-search-url="api_post_person_search"
    data-remote-load-url="api_get_person"
    ng-change="updatePerson(contact, contact.person)">

After implementing this directive, you could remove the entirety of script.js.

In your controller you have the following:

$('.select2remote').on('change', function () {
  console.log('change');
  var value = $(this).value;
  $scope.$apply(function () {
      $scope.contact.person.id = value;
  });
});

There are two problems here:

First, you are using jQuery in a controller, which you really shouldn't do.
Second, this line of code is going to fire a change event on every element with the select2remote class in the entire application that was in the DOM when the controller was instatiated.

It is likely that elements added by Angular (i.e through ng-repeat) will not have the change listener registered on them because they will be added to the DOM after the controller is instantiated (at the next digest cycle).

Also, elements outside the scope of the controller that have change events will modify the state of the controller's $scope. The solution to this, again, is to move this functionality into the directive and rely on ng-model functionality.

Remember that anytime you leave Angular's context (i.e if you are using jQuery's $.ajax functionality), you have to use scope.$apply() to reenter Angular's execution context.

I hope these suggestions help you out.