7

I'm working on an application that saves changes automatically when the user changes something, for example the value of an input field. I have written a autosave directive that is added to all form fields that should trigger save events automatically.

template:

   <input ng-model="fooCtrl.name" autosave>
   <input ng-model="fooCtrl.email" autosave>

directive:

  .directive('autosave', ['$parse', function  ($parse) {

    return {
      restrict: 'A',
      require: 'ngModel',
      link: function (scope, element, attrs, ngModel) {

        function saveIfModelChanged () {
          // save object containing name and email to server ...
        }

        ngModel.$viewChangeListeners.push(function () {
          saveIfModelChanged();
        });
      }
    };
  }]);

So far, this all works fine for me. However, when I add validation into the mix, for example validating the input field to be a valid email address, the modelValue is set to undefined as soon as the viewValue is changed to an invalid email address.

What I would like to do is this: Remember the last valid modelValue and use this when autosaving. If the user changes the email address to be invalid, the object containing name and email should still be saved to the server. Using the current valid name and the last valid email.

I started out by saving the last valid modelValue like this:

template with validation added:

   <input type="email" ng-model="fooCtrl.name" autosave required>
   <input ng-model="fooCtrl.email" autosave required>

directive with saving lastModelValue:

  .directive('autosave', ['$parse', function  ($parse) {

    return {
      restrict: 'A',
      require: 'ngModel',
      link: function (scope, element, attrs, ngModel) {

        var lastModelValue;

        function saveIfModelChanged () {

          // remeber last valid modelValue
          if (ngModel.$valid) {
             lastModelValue = ngModel.$modelValue;
          }

          // save object containing current or last valid
          // name and email to server ...
        }

        ngModel.$viewChangeListeners.push(function () {
          saveIfModelChanged();
        });
      }
    };
  }]);

My question is, how to use lastModelValue while saving, but preserving the invalid value in the view?

EDIT:

Another possibility, as suggested by Jugnu below, would be wrapping and manipulating the build in validators.

I tried to following: wrap all existing validators and remember the last valid value, to restore it if validations fails:

Object.keys(ngModel.$validators).forEach(function(validatorName, index) {
    var validator = ngModel.$validators[validatorName];
    ngModel.$validators[validatorName] = createWrapper(validatorName, validator, ngModel);
});

function createWrapper(validatorName, validator, ngModel){

  var lastValid;

  return function (modelValue){

    var result = validator(modelValue);

    if(result) {
      lastValid = modelValue;
    }else{
        // what to do here? maybe asign the value like this:
      // $parse(attrs.ngModel).assign(scope, lastValid);
    }

    return result;
  };
}

But I'm not sure how to continue with this approach either. Can I set the model value without AngularJS kicking in and try to validate that newly set value?

Community
  • 1
  • 1
Tim Büthe
  • 62,884
  • 17
  • 92
  • 129
  • Given that you are also developing the backend of your app, I would consider outsourcing the whole problem into the backend. If the backend would happen to be node.js, I would just update those values `v` to my database for which `v!==undefined` hold and save myself some working hours. I understand that you might not be able to do this or do this project for learning purposes, just my thoughs about dev efficiency. – Kevin Dreßler Sep 21 '15 at 16:35

3 Answers3

3

Since you are sending the entire object for each field modification, you have to keep the last valid state of that entire object somewhere. Use case I have in mind:

  1. You have a valid object { name: 'Valid', email: 'Valid' }.
  2. You change the name to invalid; the autosave directive placed at the name input knows its own last valid value, so the correct object gets sent.
  3. You change the email to invalid too. The autosave directive placed at the email input knows its own last valid value but NOT that of name. If the last known good values are not centralized, an object like { name: 'inalid', email: 'Valid' } will be sent.

So the suggestion:

  1. Keep a sanitized copy of the object you are editing. By sanitized I mean that any invalid initial values should be replaced by valid pristine ones (e.g. zeros, nulls etc). Expose that copy as a controller member, e.g. fooCtrl.lastKnowngood.
  2. Let autosave know the last known good state, e.g. as:

    <input ng-model="fooCtrl.email" autosave="fooCtrl.lastKnowngood" required />
    
  3. Keep the last known good local value in that object; utilize the ng-model expression, e.g. as:

    var lastKnownGoodExpr = $parse(attrs.autosave);
    var modelExpr = $parse(attrs.ngModel);
    
    function saveIfModelChanged () {
        var lastKnownGood = lastKnownGoodExpr(scope);
    
        if (ngModel.$valid) {
            // trick here; explanation later
            modelExpr.assign({fooCtrl: lastKnownGood}, ngModel.$modelValue);
        }
    
        // send the lastKnownGood object to the server!!!
    }
    
  4. Send the lastKnownGood object.

The trick, its shortcomings and how can it be improved: When setting the local model value to the lastKnownGood object you use a context object different than the current scope; this object assumes that the controller is called fooCtrl (see the line modelExpr.assign({fooCtrl: lastKnownGood}, ...)). If you want a more general directive, you may want to pass the root as a different attribute, e.g.:

<input ng-model="fooCtrl.email" autosave="fooCtrl.lastKnowngood" required
    autosave-fake-root="fooCtrl" />

You may also do some parsing of the ng-model expression yourself to determine the first component, e.g. substring 0 → 1st occurence of the dot (again simplistic).

Another shortcoming is how you handle more complex paths (in the general case), e.g. fooCtrl.persons[13].address['home'].street - but that seems not to be your use case.


By the way, this:

ngModel.$viewChangeListeners.push(function () {
    saveIfModelChanged();
});

can be simplified as:

ngModel.$viewChangeListeners.push(saveIfModelChanged);
Nikos Paraskevopoulos
  • 39,514
  • 12
  • 85
  • 90
  • Thanks, I like the suggestions! That makes me think: If I start to fiddle with the model expression, I could create a proxy for the original object. I would then set values to the proxy if the original model gets invalid and send the proxy object to the server. – Tim Büthe Sep 17 '15 at 13:10
  • That seems close to the idea and could work as well, if I am getting it right - some example would help. – Nikos Paraskevopoulos Sep 17 '15 at 14:35
3

I have created a simple directive that serves as a wrapper on the ng-model directive and will keep always the latest valid model value. It's called valid-ng-model and should replace the usage of ng-model on places where you want to have the latest valid value.

I've created an example use case here, I hope you will like it. Any ideas for improvements are welcomed.

This is the implementation code for valid-ng-model directive.

app.directive('validNgModel', function ($compile) {
  return {
      terminal: true,
      priority: 1000,
      scope: {
        validNgModel: '=validNgModel'
      },
      link: function link(scope, element, attrs) {

        // NOTE: add ngModel directive with custom model defined on the isolate scope
        scope.customNgModel = angular.copy(scope.validNgModel);
        element.attr('ng-model', 'customNgModel'); 
        element.removeAttr('valid-ng-model');

        // NOTE: recompile the element without this directive
        var compiledElement = $compile(element)(scope);
        var ngModelCtrl = compiledElement.controller('ngModel');

        // NOTE: Synchronizing (inner ngModel -> outside valid model)
        scope.$watch('customNgModel', function (newModelValue) {
          if (ngModelCtrl.$valid) {
            scope.validNgModel = newModelValue;
          }
        });

        // NOTE: Synchronizing (outside model -> inner ngModel)
        scope.$watch('validNgModel', function (newOutsideModelValue) {
          scope.customNgModel = newOutsideModelValue;
        });
      }
    };
});

Edit: directive implementation without isolate scope: Plunker.

S.Klechkovski
  • 4,005
  • 16
  • 27
  • I played around with the plunker and it looks very promising! I'am gonna try to integrate it in our application and get back with the results. Do you think it would be problematic/hard to merge the `autosave` and `valid-ng-model` directives? – Tim Büthe Sep 21 '15 at 20:52
  • Thanks, I am glad that you liked it. For merging the two directive it's not a problem but IMHO they are about two different concerns that I don't like to be mixed. But if you think that it would be better like that then feel free to merge them, I can help you for that too. – S.Klechkovski Sep 21 '15 at 21:28
  • Thanks, but I think I can manage to merge the directives. However, I wonder about another aspect: You directive creates an isolated scope which neither ngModel nor my autosave directive does. Would it be possible without an isolated scope? Do we have to asign `customNgModel` to the scope to begin with? – Tim Büthe Sep 22 '15 at 16:30
  • I'm actually facing different problems because of that isolated scope: 1. inside my autosave directive I have to use `scope.$parent.$eval(...);` now, instead of `scope.$eval(...);`, so I would have to search both the current scope and the parent scope depending on using `valid-ng-model` or `ng-model` 2. bootstrap-ui's datepicker-popup stops working when changing `ng-model` to `valid-ng-model`. 3. `ng-disabled="foo.enabled"` stops working when changing `ng-model="foo.title"` to `valid-ng-model="foo.title"` – Tim Büthe Sep 22 '15 at 17:24
  • The basic idea in my approach is to bind the ng-model directive to some inner model so I can control the values that are passed to my outer(real) model while keeping it's functionality. That is the reason why the directive needs new scope which does not need to be isolated. Here is the directive implemented with new scope that prototypicaly inherits from it's parent. It should solve the #1 and #3 issues that you have, for the #2 I'm not sure and you should try. Tell me what you think, if it works for you I will update my answer. Link: http://plnkr.co/edit/3sCddqzHYy99XcIh8U5f?p=preview – S.Klechkovski Sep 22 '15 at 18:20
  • @TimBüthe, I've updated the answer with the latest implementation of valid-ng-model directive. If you're happy with the provided solution I would suggest you to handle the bounty points as reward before time goes up. Thanks. – S.Klechkovski Sep 26 '15 at 07:44
2

Angular default validators will only assign value to model if its valid email address.To overcome that you will need to override default validators.

For more reference see : https://docs.angularjs.org/guide/forms#modifying-built-in-validators

You can create a directive that will assign invalide model value to some scope variable and then you can use it.

I have created a small demo for email validation but you can extend it to cover all other validator.

Here is fiddle : http://plnkr.co/edit/EwuyRI5uGlrGfyGxOibl?p=preview

Juned Lanja
  • 1,466
  • 10
  • 21
  • wrapping and manipulating the build in validators seems like a good alternative. I tried to go down this path but stuck. Could you take a look at my edit to the question please? Do you have any further tips? – Tim Büthe Sep 21 '15 at 15:06
  • I don't see this as an option, overriding the validators will mean that they will need to return validation success even if the validation is not successful in order to retain the model value. That would break their functionality. This line is taken from the source code for ngModelCtrl and is executed after all validators are run: ctrl.$modelValue = allValid ? modelValue : undefined; – S.Klechkovski Sep 21 '15 at 18:15