13

I am using knockout.js to display a list of employees. I have a single hidden modal markup on the page. When the "details" button for a single employees is clicked, I want to data-bind that employee to the modal popup. I am using the ko.applyBindings(employee, element) but the problem is when the page loads, it is expecting the modal to start off as bound to something.

So I'm wondering, is there a trick/strategy to do a late/deferred databinding? I looked into virtual bindings but the documentation was not helpful enough.

Thanks!

Adam Levitt
  • 10,316
  • 26
  • 84
  • 145

3 Answers3

37

I would like to propose a different way to work with modals in MVVVM. In MVVM, the ViewModel is data for the View, and the View is responsible for the UI. If we examine this proposal:

this.detailedEmployee = ko.observable({}),

var self = this;
this.showDetails = function(employee){
    self.detailedEmployee(employee);
    $("#dialog").dialog("show"); //or however your dialog works
}

I strongly agree with this.detailedEmployee = ko.observable({}), but I am in strong disagreement with this line: $("#dialog").dialog("show");. This code is placed in the ViewModel and shows the modal window, wherein fact it is View's responsibility, so we screw-up the MVVM approach. I would say this piece of code will solve your current task but it could cause lots of problems in future.

  • When closing the popup, you should set detailedEmployee to undefined to have your main ViewModel in a consistent state.
  • When closing the popup, you might want to have validation and the possibility to discard the close operation when you want to use another modal's component in the application

As for me, these points are very critical, so I would like to propose a different way. If we "forget" that you need to display data in popup, binding with could solve your issue.

this.detailedEmployee = ko.observable(undefined);
var self = this;
this.showDetails = function(employee){
    self.detailedEmployee(employee);
}

<div data-bind="with: detailedEmployee">
Data to show
</div>

As you can see, your ViewModel don't know anything about how data should be shown. It knows only about data that should be shown. The with binding will display content only when detailedEmployee is defined. Next, we should find a binding similar to with but one that will display content in the popup. Let's give it the name modal. Its code is like this:

ko.bindingHandlers['modal'] = {
    init: function(element) {
        $(element).modal('init');
        return ko.bindingHandlers['with'].init.apply(this, arguments);
    },
    update: function(element, valueAccessor) {
        var value = ko.utils.unwrapObservable(valueAccessor());
        var returnValue = ko.bindingHandlers['with'].update.apply(this, arguments);

        if (value) {
            $(element).modal('show');
        } else {
            $(element).modal('hide');
        }

        return returnValue;
    }
};

As you can see, it uses the with plugin internally, and shows or hide a popup depending on value passed to binding. If it is defined - 'show'. If not - 'hide'. Its usage will be the as with:

<div data-bind="modal: detailedEmployee">
    Data to show
</div>

The only thing you need to do is to use your favorite modals plugin. I prepared an example with the Twitter Bootstrap popup component: http://jsfiddle.net/euvNr/embedded/result/

In this example, custom binding is a bit more powerful; you could subscribe the onBeforeClose event and cancel this event if needed. Hope this helps.

alex
  • 6,818
  • 9
  • 52
  • 103
Romanych
  • 1,319
  • 10
  • 10
  • I like the approach of knowing when to show the modal window when the value of detailedEmployee is not undefined, but this approach seems awfully verbose. Is there perhaps a less verbose way to keep the separation of concerns? – Adam Levitt May 17 '12 at 14:27
  • A followup question for you -- let's say I want to add a click handler to the "save changes" button that does an async post to the server... where would you put that click handler binding? – Adam Levitt May 17 '12 at 14:50
  • Actually in my example I have click handler for save button: `data-bind="click: $parent.SaveUser"`. This method is placed in main View Model and Backend communication should be placed there. Usually in main view model I have these properties/methods: `EditingItem`, `EditItem`, `SaveItem`, `CancelEditItem`. Biggest issue for me is mix between `Editing` and `Edit` – Romanych May 17 '12 at 16:09
  • The drawback using the 'with' binding is, that it (re)creates DOM elements. In my case I use Telerik MVC controls, that bind their implementation to the DOM element - in my case eg. a DatePicker bound to the Input element of the whole control. When using the 'with' binding, the implementation of the datepicker is gone, because the Input element was recreated. – Andreas Jun 22 '12 at 11:14
  • 1
    Guys I know its a little old post but none of the jsfiddle code seems to be working. – pravin Feb 11 '15 at 07:27
  • Hi @pravin, I created a new JSFiddle using Bootstrap 3 that works. Hopefully you find it useful: https://jsfiddle.net/BitWiseGuy/4u5egybp/ – Alexander May 05 '16 at 01:49
  • thanks man.. Looks good..tried add a +1 does not seem to be working.. so the comment.. – pravin May 05 '16 at 10:36
3

The JSFiddle linked to in the answer provided by @Romanych didn't seem to work anymore.

So, I built my own example (based upon his original fiddle) with full CRUD support and basic validation using Bootstrap 3 and the Bootstrap Modal library: https://jsfiddle.net/BitWiseGuy/4u5egybp/

Custom Binding Handlers

ko.bindingHandlers['modal'] = {
  init: function(element, valueAccessor, allBindingsAccessor) {
    var allBindings = allBindingsAccessor();
    var $element = $(element);
    $element.addClass('hide modal');

    if (allBindings.modalOptions && allBindings.modalOptions.beforeClose) {
      $element.on('hide', function() {
        var value = ko.utils.unwrapObservable(valueAccessor());
        return allBindings.modalOptions.beforeClose(value);
      });
    }
  },
  update: function(element, valueAccessor) {
    var value = ko.utils.unwrapObservable(valueAccessor());
    if (value) {
      $(element).removeClass('hide').modal('show');
    } else {
      $(element).modal('hide');
    }
  }
};

Example Usage

The View

<div data-bind="modal: UserBeingEdited" class="fade" role="dialog" tabindex="-1">
  <form data-bind="submit: $root.SaveUser">
    <div class="modal-header">
      <a class="close" data-dismiss="modal">×</a>
      <h3>User Details</h3>
    </div>
    <div class="modal-body">
      <div class="form-group">
        <label for="NameInput">Name</label>
        <input type="text" class="form-control" id="NameInput" placeholder="User's name"
           data-bind="value: UserBeingEdited() && UserBeingEdited().Name, valueUpdate: 'afterkeydown'">
      </div>
      <div class="form-group">
        <label for="AgeInput">Age</label>
        <input type="text" class="form-control" id="AgeInput" placeholder="User's age"
           data-bind="value: UserBeingEdited() && UserBeingEdited().Age, valueUpdate: 'afterkeydown'">
      </div>
      <!-- ko if: ValidationErrors() && ValidationErrors().length > 0 -->
      <div class="alert alert-danger" style="margin: 20px 0 0">
        Please correct the following errors:
        <ul data-bind="foreach: { data: ValidationErrors, as: 'errorMessage'     }">
          <li data-bind="text: errorMessage"></li>
        </ul>
      </div>
      <!-- /ko -->
    </div>
    <div class="modal-footer">
      <button type="button" data-dismiss="modal" class="btn btn-default">Cancel</button>
      <button type="submit" class="btn btn-primary">Save Changes</button>
    </div>
  </form>
</div>

The ViewModel

/* ViewModel for the individual records in our collection. */
var User = function(name, age) {
  var self = this;
  self.Name = ko.observable(ko.utils.unwrapObservable(name));
  self.Age = ko.observable(ko.utils.unwrapObservable(age));
}

/* The page's main ViewModel. */
var ViewModel = function() {
  var self = this;
  self.Users = ko.observableArray();

  self.ValidationErrors = ko.observableArray([]);

  // Logic to ensure that user being edited is in a valid state
  self.ValidateUser = function(user) {
    if (!user) {
      return false;
    }

    var currentUser = ko.utils.unwrapObservable(user);
    var currentName = ko.utils.unwrapObservable(currentUser.Name);
    var currentAge = ko.utils.unwrapObservable(currentUser.Age);

    self.ValidationErrors.removeAll(); // Clear out any previous errors

    if (!currentName)
      self.ValidationErrors.push("The user's name is required.");

    if (!currentAge) {
      self.ValidationErrors.push("Please enter the user's age.");
    } else { // Just some arbitrary checks here...
      if (Number(currentAge) == currentAge && currentAge % 1 === 0) { // is a whole number
        if (currentAge < 2) {
          self.ValidationErrors.push("The user's age must be 2 or greater.");
        } else if (currentAge > 99) {
          self.ValidationErrors.push("The user's age must be 99 or less.");
        }
      } else {
        self.ValidationErrors.push("Please enter a valid whole number for the user's age.");
      }
    }

    return self.ValidationErrors().length <= 0;
  };

  // The instance of the user currently being edited.
  self.UserBeingEdited = ko.observable();

  // Used to keep a reference back to the original user record being edited
  self.OriginalUserInstance = ko.observable();

  self.AddNewUser = function() {
    // Load up a new user instance to be edited
    self.UserBeingEdited(new User());
    self.OriginalUserInstance(undefined);
  };

  self.EditUser = function(user) {
    // Keep a copy of the original instance so we don't modify it's values in the editor
    self.OriginalUserInstance(user);

    // Copy the user data into a new instance for editing
    self.UserBeingEdited(new User(user.Name, user.Age));
  };

  // Save the changes back to the original instance in the collection.
  self.SaveUser = function() {
    var updatedUser = ko.utils.unwrapObservable(self.UserBeingEdited);

    if (!self.ValidateUser(updatedUser)) {
      // Don't allow users to save users that aren't valid
      return false;
    }

    var userName = ko.utils.unwrapObservable(updatedUser.Name);
    var userAge = ko.utils.unwrapObservable(updatedUser.Age);

    if (self.OriginalUserInstance() === undefined) {
      // Adding a new user
      self.Users.push(new User(userName, userAge));
    } else {
      // Updating an existing user
      self.OriginalUserInstance().Name(userName);
      self.OriginalUserInstance().Age(userAge);
    }

    // Clear out any reference to a user being edited
    self.UserBeingEdited(undefined);
    self.OriginalUserInstance(undefined);
  }

  // Remove the selected user from the collection
  self.DeleteUser = function(user) {
    if (!user) {
      return falase;
    }

    var userName = ko.utils.unwrapObservable(ko.utils.unwrapObservable(user).Name);

    // We could use another modal here to display a prettier dialog, but for the
    // sake of simplicity, we're just using the browser's built-in functionality.
    if (confirm('Are you sure that you want to delete ' + userName + '?')) {
      // Find the index of the current user and remove them from the array
      var index = self.Users.indexOf(user);
      if (index > -1) {
        self.Users.splice(index, 1);
      }
    }
  };
}

Initializing Knockout with the View and the ViewModel

var viewModel = new ViewModel();

// Populate the ViewModel with some dummy data
for (var i = 1; i <= 10; i++) {
  var letter = String.fromCharCode(i + 64);
  var userName = 'User ' + letter;
  var userAge = i * 2;
  viewModel.Users.push(new User(userName, userAge));
}

// Let Knockout do its magic!
ko.applyBindings(viewModel);
Alexander
  • 2,320
  • 2
  • 25
  • 33
  • Nice, but is there anyway to get rid of the additional `data-bind="value: UserBeingEdited() && UserBeingEdited().Name` for each and every value that you want to bind? – morleyc Aug 15 '17 at 19:48
1

I would create another observable that wraps the employee.

this.detailedEmployee = ko.observable({}),

var self = this;
this.showDetails = function(employee){
    self.detailedEmployee(employee);
    $("#dialog").dialog("show"); //or however your dialog works
}

Attach the click to showDetails. Then you can just call applyBindings on page load.

  • Isaac, thanks so much for your reply, however, I'm not sure I understand... take the following:
    So I load up the "rows" of divs for each employee, but I don't have a specific one that I want to bind to the modal until I pick an employee from the list. Does that make sense?
    – Adam Levitt May 16 '12 at 21:42
  • Actually, I think your answer is definitely in the right direction, but I'm not fully clear on how to proceed -- in the modal I have a field that looks like this: ... and the job looks like this: function Job(data) {}... where data has a CompanyName property on it. – Adam Levitt May 16 '12 at 21:55
  • I'm getting closer -- the last piece of the puzzle is getting it to understand a field on the bound Employee. If i use nothing seems to actually appear. – Adam Levitt May 16 '12 at 22:05
  • I got it. I was missing the detailedEmployee() parenthesis, because it's observable. Thanks again for your help Isaac! – Adam Levitt May 16 '12 at 22:18
  • Can't answer my own question but here is the solution: function EmployeeViewModel() { var self = this; this.detailedEmployee = ko.observable({}); this.showModal= function(employee) { self.detailedEmployee(employee); $('#employeeDetails').modal('show'); } } No special ko.applyBindings was needed. – Adam Levitt May 16 '12 at 22:20
  • I'm glad you figured it out. You're right that I didn't quite have the scoping right. I've edited a bit posterity. –  May 16 '12 at 23:07