6

I'm trying to have a twitter bootstrap modal open to a window that has a text area in it which is editable, then on save, it saves the appropriate data. My current code:

HTML:

<table  class="display table table-striped">
    <tbody data-bind="foreach: entries">
        <tr>
            <td>
                Placeholder
            </td>
            <!-- ko foreach: entry_data -->
            <td>
                <div class="input-group">
                    <input type="text" class="form-control col-sm-2" data-bind="value: entry_hours">
                    <span class="input-group-addon"><a class="comment" data-bind="click: function() { $root.modal.comment($data); $root.showModal(); }, css: { 'has-comment': comment.length > 0, 'needs-comment': comment.length == 0 }, attr: { title: comment }"><span class="glyphicon glyphicon-comment"></span></a></span>
                </div>
            </td>
            <!-- /ko -->
        </tr>
    </tbody>
</table>

<!-- Modal template -->
<script id="commentsModal" class="modal-dialog" type="text/html">
<div class="modal-dialog">
    <div class="modal-content">
        <div class="modal-header">
            <button type="button" class="close" data-bind="click:close" aria-hidden="true">&times;</button>
            <h4 data-bind="html:header" class="modal-title"></h4>
        </div>
        <div class="modal-body">
            <textarea class="form-control" rows="3" data-bind="value: $root.modal.comment.comment"></textarea>
        </div>
        <div class="modal-footer">
            <button type="button" class="btn btn-default" data-bind="click:close,html:closeLabel">Close</button>
            <button type="button" class="btn btn-primary" data-bind="click:action,html:primaryLabel" id="save-changes">Save changes</button>
        </div>
    </div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</script>

<!-- Create a modal via custom binding -->

<div data-bind="bootstrapModal:modal" class="modal fade" id="commentsModal" tabindex="-1" role="dialog" data-keyboard="false" data-backdrop="static"></div>

JS:

/* Custom binding for making modals */
ko.bindingHandlers.bootstrapModal = {
    init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
        var props = valueAccessor(),
            vm = bindingContext.createChildContext(viewModel);
        ko.utils.extend(vm, props);
        vm.close = function() {
            vm.show(false);
            vm.onClose();
        };
        vm.action = function() {
            vm.onAction();
        }
        ko.utils.toggleDomNodeCssClass(element, "modal fade", true);
        ko.renderTemplate("commentsModal", vm, null, element);
        var showHide = ko.computed(function() {
            $(element).modal(vm.show() ? 'show' : 'hide');
        });
        return {
            controlsDescendantBindings: true
        };
    }
}

var entriesdata = [{"entry_id":"51794","project_id":"2571","user_id":"89","entry_data":[{"entry_data_id":"359192","entry_id":"51794","entry_hours":"0.00","entry_date":"2013-12-22","comment":""},{"entry_data_id":"359193","entry_id":"51794","entry_hours":"8.00","entry_date":"2013-12-23","comment":"Test comment"},{"entry_data_id":"359194","entry_id":"51794","entry_hours":"8.00","entry_date":"2013-12-24","comment":"Test comment"},{"entry_data_id":"359195","entry_id":"51794","entry_hours":"0.00","entry_date":"2013-12-25","comment":""},{"entry_data_id":"359196","entry_id":"51794","entry_hours":"8.00","entry_date":"2013-12-26","comment":"Test comment"},{"entry_data_id":"359197","entry_id":"51794","entry_hours":"8.00","entry_date":"2013-12-27","comment":"Test comment"},{"entry_data_id":"359198","entry_id":"51794","entry_hours":"0.00","entry_date":"2013-12-28","comment":""}]}];
var projectsdata = [{"project_txt":"Test Project","project_id":12345}];
var TimeEntriesModel = function(entries, projects) {
    var self = this;

    self.projects = ko.observableArray(projects);

    self.entries = ko.observableArray(ko.utils.arrayMap(entries, function(entry) {
        return {
                entry_id : entry.entry_id,
                project_id : entry.project_id,
                user_id : entry.user_id,
                entry_data : ko.observableArray(entry.entry_data)
                }
    }));

    self.save = function () {
        ko.utils.stringifyJson(self.entries);

    }

    self.modal = {
        header: "Add/Edit Comment",
        comment: ko.observableArray([{comment: "test"}]),
        closeLabel: "Cancel",
        primaryLabel: "Save",
        show: ko.observable(false), /* Set to true to show initially */
        onClose: function() {
            self.onModalClose();
        },
        onAction: function() {
            self.onModalAction();
        }
    }
    console.log(ko.isObservable(self.modal.comment));
    self.showModal = function() {
        self.modal.show(true);
    }

    self.onModalClose = function() {
        // alert("CLOSE!");
    }
    self.onModalAction = function() {
        // alert("ACTION!");
        self.modal.show(false);
    }

}

ko.applyBindings(new TimeEntriesModel(entriesdata, projectsdata));

Fiddle: http://jsfiddle.net/sL3HK/

As you can see in the fiddle, the modal opens with the text box, but I'm unable to figure out how to get the 'comment' text into the modal or update the comment when the 'save' button is pressed.

Any ideas?

Also, I'm very new to Knockout, so if there's anything in there that doesn't look quite right, please feel free to correct me on it.

UPDATE:

I've been fiddling with the code, and have been able to get the "comment" into the modal, but I've not been able to successfully update it up to this point. And another problem I will eventually run into is that I only want the comment to be updated when "Save" is clicked, rather than the normal update on blur. I really think I'm going about this the wrong way, but I'm not sure what the right way is. Any more help is greatly appreciated.

Updated fiddle.

Samsquanch
  • 8,866
  • 12
  • 50
  • 89

2 Answers2

16

Here is a JsFiddle in which you should be able to edit comment for each entry. Here is how I proceeded to obtain this.

The ViewModels

First, I like to divide my views into partials. For each type of partial, I create a ViewModel. And an "upper level" ViewModel is used as a container for all the partial ViewModels. Here you'll need a EntryDataViewModel which I defined this way :

var EntryDataViewModel = function (rawEntryData) {
    var self = this;
    self.entry_data_id = rawEntryData.entry_data_id;
    self.entry_id = rawEntryData.entry_id;
    self.entry_hours = rawEntryData.entry_hours;
    self.entry_date = rawEntryData.entry_date;
    self.comment = ko.observable(rawEntryData.comment);
} 

Basically, this constructor does the conversion from your raw data to something you will be able to manipulate in your views. Depending on what you want to do, you can make things observable or not. comment is used in some bindings and is expected to change. We want the page to react dynamically to its changes, so let's make it observable.
Because of this change, we will change the way we create the "upper level" ViewModel (here TimeEntriesModel), and in particular :

self.entries = ko.observableArray(ko.utils.arrayMap(entries, function (entry) {
    return {
        entry_id: entry.entry_id, //same as before
        project_id: entry.project_id, // same as before
        user_id: entry.user_id, // same as before
        entry_data: ko.observableArray(entry.entry_data.map(function (entry_data) {
            return new EntryDataViewModel(entry_data); // here we use the new constructor
        }))
    }
}));

Now our ViewModels are ready to be updated. So let's change the modal.

The Modal

Again, in the modal, the comment will be subject to change, and we want to retrieve its value (to update our EntryData). So it's an observable.
Now we have to inform the modal of which EntryData we are modifying (and I think this is the main part your code was lacking). We can do this by keeping a reference of the EntryData that was used to open the modal :

self.modal = {
   ...
   comment:ko.observable(""),
   entryData : undefined,
   ...
}

Last thing to do is to update all these variables when you open the modal :

self.showModal = function (entryDataViewModel) {
    // modal.comment is already updated in your bindings, but logic can be moved here.
    self.modal.entryData = entryDataViewModel; // keep track of who opened the modal
    self.modal.show(true);
}

And when you save :

self.onModalAction = function () {
    self.modal.entryData.comment(self.modal.comment()); //save the modal's comment into the entryData.
    self.modal.show(false);
}

Conclusion

I did not want to change all your bindings and code, thus there were a lot of little changes and I think you'll have to play with the code to see how they affect the behavior of the page, how it works. My solution is not perfect of course. There remains some logic in your HTML markup that must be moved to the JS and I'm not sure you really need all the custom binding stuff. Moreover, I'm not happy about the modal. The modal stuff should belong to a EntryDataViewModel since editing the comment acts on one EntryData, but as I said, I did not want to change all your code. Tell me if you have problems with my solution :).

Update (some hints for going further)

When I said "moving logic from HTML to JS", here is what I meant. The following binding looks to complicated to belong to HTML markup.

<a class="comment" data-bind="click: function() { $root.modal.comment(comment()); $root.showModal($data); }, css: { 'has-comment': comment().length > 0, 'needs-comment': comment().length == 0 }, attr: { title: comment() }">

Some things you could do : move $root.modal.comment(comment()) to showModal, then your click binding becomes click : $root.showModal. Even the "needs-comment" binding has a logic, you could add a method needsComment to your EntryDataViewModel that contains this logic.
Keep in mind that HTML markup should not contain any logic, it should just make calls to JS functions. If a function acts on an partial of the view (for example, an EntryData), then this function belongs to the partial view model (this is why I was complaining about the modal, that acts on only one EntryData but here is located in the TimesEntriesModel). If a function manipulates a set of elements (for example, if you create an "add" button), this function belongs in the container ViewModel.

This was a VERY long and specific answer. Apologies for that. You should be able to find a lot of resources on Model View ViewModel (MVVM) on the web, that will help you in your journey :)

Paul D.
  • 2,022
  • 19
  • 19
  • This was what I was originally going off of, but the problem came when I was trying to connect it back to the original comment which is nested inside of the entry_date observableArray. I can't seem to keep the connection between the original observable and the modal's observable. – Samsquanch Dec 24 '13 at 15:54
  • I'm not sure you quite see what I'm trying to do with this (or maybe you do and I'm just not seeing what you're getting at), but the idea is that the TimeEntriesModal holds entry data, and this entry data includes 7 entries (corresponding to the days of the week). These 7 entries each have a number of hours and a comment associated with them. I'm trying to get the comment from these entries into the modal, which can then be edited and saved (or not). The way your describing doesn't seem to fit in with this. If I'm wrong, please correct me. Thanks for your help so far! – Samsquanch Dec 27 '13 at 16:10
  • 1
    @Samsquanch Please check this fiddle : http://jsfiddle.net/Qyqve/1/ Tell me if this is the behavior you expect. I will help you understand what I've done if this is what you expect :) – Paul D. Dec 27 '13 at 17:27
  • This seems to work perfectly, thanks a bunch. If you'll update your answer with an overview of what you did to make it work I would greatly appreciate it, and I'll accept it. – Samsquanch Dec 27 '13 at 17:49
  • One minor thing that I see is if I do `ko.utils.stringifyJson(self.entries)` I only get back the main entry data (entry id, project id, user id) instead of all that information plus the data for the actual entries that goes with each of them now. Is there a way to fix that? – Samsquanch Dec 27 '13 at 17:52
  • @Samsquanch concerning your JSON problem, using `ko.toJSON(self.entries)` seems to work for me. Tell me if this helps – Paul D. Dec 27 '13 at 18:36
  • One last thing: in the last sentence of your answer you mention that there is some HTML that should be moved and some bindings that you don't think I need -- would you care to expand on those points? I'm about 2 days into learning Knockout, so any pointers on things I'm doing wrong are greatly appreciated. – Samsquanch Dec 27 '13 at 18:45
  • I hate to keep pulling you back in here to further expand on things that are probably obvious, but would you mind adding in an example of simplifying the click function and the CSS function? I did actually attempt to do both of these things with an earlier version of my code, but was unsuccessful. What I posted ended up being the only way I could get both of those to consistently function as expected. – Samsquanch Dec 27 '13 at 19:14
  • 1
    @Samsquanch see http://jsfiddle.net/Qyqve/2/ . I changed the binding and moved some logic into `EntryDataViewModel`. – Paul D. Dec 27 '13 at 20:25
  • Thanks a bunch. Could you explain why `entryDataViewModel` is implicitly passed when calling `showModal`? I've been trying to pass things to these functions, when it doesn't seem that I actually need to at all. I actually took the CSS bit a step further and consolidated it into one computed function: http://jsfiddle.net/Qyqve/3/ – Samsquanch Dec 27 '13 at 20:38
  • When calling a function from an event such as click, Knockout passes two arguments to that function : the current context (= `$data`) and the event object. Actually, I could also have used the `this` keyword here, but I don't like using `this` too much in JS as it is the source of many mistakes. Use your browser developers tools to see how Knockout behaves with the bindings. If you're using Chrome, I recommend the plugin "Knockout Context Debugger". – Paul D. Dec 27 '13 at 20:43
  • I'll give it a try. Thanks a bunch! I'll award your extra 100 rep when it lets me. – Samsquanch Dec 27 '13 at 20:52
  • My pleasure, thanks :) Hopefully I haven't said any big mistake in this loooong answer/conversation. Keep on practicing ! Knockout is cool and worth it ! – Paul D. Dec 27 '13 at 20:56
2

For what it's worth, I wrote the knockout-modal project to make modals easier to work with when using Knockout.

Would welcome any feedback on it, and in any case I hope it is helpful to look at.

Brian M. Hunt
  • 81,008
  • 74
  • 230
  • 343