64
votes

Note: This is not about showing a modal dialog with AngularJS, that topic has plenty of questions and answers!

This question is about how to react to both OK and Cancel within a modal dialog on a page. Let's say you've got a scope with just one variable in it:

$scope.description = "Oh, how I love porcupines..."

If I provide you with a modal dialog on the page and use ng-model="description" within that dialog, all of the changes you make are actually made in real time to the description itself as you type. That's bad, because then how do you cancel out of that dialog?

There's this question that says to do what I explain below. The accepted answer for it is the same "solution" I came up with: AngularJS: Data-bound modal - save changes only when "Save" is clicked, or forget changes if "Cancel" is clicked

I can see how to do it if clicking the button to bring up a modal goes back to a function in the back and that creates a temporary copy of the relevant data for the modal and then pops up the modal. Then "OK" (or "Save" or whatever) could copy the temporary values to the actual model values.

main.js (excerpt):

$scope.descriptionUncommitted = $scope.description;

$scope.commitChanges = function () {
  $scope.description = $scope.descriptionUncommitted;
}

main.html (excerpt):

<input type="text" ng-model="descriptionUncommitted"/>

<button ng-click="commitChanges()">Save</button>

The problem with that is it's not declarative! In fact, it's nothing like AngularJS anywhere else. It's almost as though we need an ng-model-uncommitted="description" where they could make all the changes they want but they only get committed when we trigger with another declaration. Is there such a thing in a plugin somewhere or is AngularJS itself adding it?

Edit: It seems that an example of a different way of doing it might be in order.

main.js:

$scope.filename = "panorama.jpg";
$scope.description = "A panorama of the mountains.";

$scope.persist = function () { // Some function to hit a back end service. };

main.html:

<form>
  <input type="text" ng-model-uncommitted="filename"/>
  <input type="text" ng-model-uncommitted="description"/>

  <button ng-commit ng-click="persist()">Save</button>
  <button ng-discard>Cancel</button>
</form>

I stuck a form tag around it because I don't know how you would group the items so it was clear it was all part of the same "transaction" (for lack of a better word). But there would need to be some way that this could all happen automatically and the cloned copies of the model variables are used for initial values, used for input and updated automatically, validated, etc. and then finally discarded or copied to the same value that initially was used to create them if the user decides to commit.

Isn't something like this easier than code in the controller to do that work over and over again for 20 modals in a big website? Or am I nuts?

6
Are you looking for a cancel button that automatically reverts changes to a model made in a modal? How about refreshing the model when the cancel button is clicked so the model will be overwritten without the need for additional temporary variables? Can you provide an example of the syntax/mark-up you are looking for.Jason
You can't get away with allowing the changes to occur because the user might literally see an instance of {{description}} on the page updating as they type (or values recalculating in the case of a number, etc.). I'll add to the above to provide some ideas for what I'd like to see. I was just hoping something already existed.John Munsch
You're definitely not nuts. I'm not clear on description and altDescription. Are they two separate models and not your version of a backup? And in your example, what would those fields show? The edited or unedited data?Sharondio
Giving this a little more thought, I'm pretty sure you can accomplish what you want in a directive.Sharondio
I've tried to make it clearer that the values are unrelated, I just wanted to show that this should work even when there are multiple model values. I expect that you'd bring up the page and you'd see the inputs filled with the current values for filename and description. After editing them if the user hits "Save" those changes are pushed into the model variables just as if you had used ng-model for each one and the ng-click could trigger something to push that on to a back end service if so desired. If the user hits cancel then those changes are discarded and the model values never changed.John Munsch

6 Answers

23
votes

Basically, in angular if something is not declarative, you make a directive.

 .directive('shadow', function() {
  return {
    scope: {
      target: '=shadow'            
    },
    link: function(scope, el, att) {
      scope[att.shadow] = angular.copy(scope.target);

      scope.commit = function() {
        scope.target = scope[att.shadow];
      };
    }
  };

Then:

  <div shadow="data">
    <input ng-model="data">
    <button ng-click="commit()">save</button>
  </div>

So data inside the shadow directive will be a copy of the original data. And it will be copied back to the original when the button is clicked.

And here is working example: jsbin

I've not tested it beyond this example, so it may not work in other cases, but I think it gives an idea of the possibilites.

Edit:

Another example with an object instead of a string, and several fields in the form (an additional angular.copy is required here): jsbin

Edit2, angular versions 1.2.x

As per this change, the input inside the directive is not accessing the isolated scope anymore. One alternative is creating a non-isolated child scope (scope:true), to hold the copy of the data and accessing the parent scope for saving it.

So for later versions of angular, this is the same approach as before slightly modified to do the trick:

.directive('shadow', function() {
  return {
    scope: true,
    link: function(scope, el, att) {
      scope[att.shadow] = angular.copy(scope[att.shadow]);

      scope.commit = function() {
        scope.$parent[att.shadow] = angular.copy(scope[att.shadow]);
      };
    }
  };
});

Example: jsbin

Note that the problem with using $parent, is that it may break if eventually there is another scope in the middle.

21
votes

As of Angular 1.3 there is ngModelOptions directive that allows to achive the same behaviour natively.

<form name="userForm">
    <input type="text" ng-model="user.name" ng-model-options="{ updateOn: 'submit' }" name="userName">
    <button type="submit">save</button>
    <button type="button"  ng-click="userForm.userName.$rollbackViewValue();">cancel</button>
</form>

JSFiddle: http://jsfiddle.net/8btk5/104/

11
votes

Facing the same problem and going though this thread I came up with lazy-model directive that works exactly like ng-model but saves changes only when form was submitted.

Usage:

<input type="text" lazy-model="user.name">

Please note to wrap it into <form> tag otherwise lazy model will not know when to push changes to original model.

Full working demo: http://jsfiddle.net/8btk5/3/

lazyModel directive code:
(better use actual version on github)

app.directive('lazyModel', function($parse, $compile) {
  return {
    restrict: 'A',  
    require: '^form',
    scope: true,
    compile: function compile(elem, attr) {
        // getter and setter for original model
        var ngModelGet = $parse(attr.lazyModel);
        var ngModelSet = ngModelGet.assign;  
        // set ng-model to buffer in isolate scope
        elem.attr('ng-model', 'buffer');
        // remove lazy-model attribute to exclude recursion
        elem.removeAttr("lazy-model");
        return function postLink(scope, elem, attr) {
          // initialize buffer value as copy of original model 
          scope.buffer = ngModelGet(scope.$parent);
          // compile element with ng-model directive poining to buffer value   
          $compile(elem)(scope);
          // bind form submit to write back final value from buffer
          var form = elem.parent();
          while(form[0].tagName !== 'FORM') {
            form = form.parent();
          }
          form.bind('submit', function() {
            scope.$apply(function() {
                ngModelSet(scope.$parent, scope.buffer);
            });
         });
         form.bind('reset', function(e) {
            e.preventDefault();
            scope.$apply(function() {
                scope.buffer = ngModelGet(scope.$parent);
            });
         });
        };  
     }
  };
});

Actual source code on GitHub

7
votes

You seem to be over-thinking this. There isn't a plug-in because the process is pretty simple. If you want a pristine copy of the model, make one and keep it in the controller. If a user cancels, reset the model to your copy and use the FormController.$setPristine() method to make the form pristine again.

//Controller:

myService.findOne({$route.current.params['id']}, function(results) {
    $scope.myModel = results;
    var backup = results;
}

//cancel
$scope.cancel = function() {
    $scope.myModel = backup;
    $scope.myForm.$setPristine();
}

Then in your view:

<form name="myForm">

You need to name the form to create the $scope.myForm controller.

4
votes

Another way is to copy the model before editing it, and on cancel, restore the original. Angular Controller Code:

//on edit, make a copy of the original model and store it on scope
function edit(model){
  //put model on scope for the cancel method to access
  $scope.modelBeingEdited = model;
  //copy from model -> scope.originalModel
  angular.copy(model,$scope.originalModel);  
}

function cancelEdit(){
  //copy from scope.original back to your model 
  angular.copy($scope.originalModel, $scope.modelBeingEdited)  
}

Hope that helps someone!

0
votes

Here's my try at keeping it simple, making it declarative and not dependent on form tags or other stuff.

A simple directive:

.directive("myDirective", function(){
return {
  scope: {
    item: "=myDirective"
  },
  link: function($scope){
    $scope.stateEnum = {
      view: 0, 
      edit: 1
    };

    $scope.state = $scope.stateEnum.view;

    $scope.edit = function(){
      $scope.tmp1 = $scope.item.text;
      $scope.tmp2 = $scope.item.description;
      $scope.state = $scope.stateEnum.edit;
    };

    $scope.save = function(){
      $scope.item.text = $scope.tmp1;
      $scope.item.description = $scope.tmp2;
      $scope.state = $scope.stateEnum.view;
    };

    $scope.cancel = function(){
      $scope.state = $scope.stateEnum.view;
    };
  },
  templateUrl: "viewTemplate.html"
};
})

viewTemplate.html:

<div>
  <span ng-show="state == stateEnum.view" ng-click="edit()">{{item.text}}, {{item.description}}</span>
  <div ng-show="state == stateEnum.edit"><input ng-model="tmp1" type="text"/> <input ng-model="tmp2" type="text"/><a href="javascript:void(0)" ng-click="save()">save</a> <a href="javascript:void(0)" ng-click="cancel()">cancel</a></div>
</div>

Then set the context (item):

<div ng-repeat="item in myItems">
  <div my-directive="item"></div>
</div>

See it in action: http://plnkr.co/edit/VqoKQoIyhtYnge2hzrFk?p=preview