1

I've got a MVC application that I'm getting along OK with. I'm using knockout.js for binding and using it on an edit page where you can edit child records also. Everything seems to be working OK apart from when I click save, all the Date Pickers reset to todays date. The data that was saved is what is persisted to the DB but the data changes in all the Date Pickers on that page. A refresh of the page shows the correct persisted data.

Before: Before saving data.

After: After saving data.

Here is my code:

Edit Page:

@model SPMVC.Models.SeasonViewModel
@using Newtonsoft.Json

@{
    ViewBag.Title = "Edit Season";
}

@{ string data = JsonConvert.SerializeObject(Model); }

@section scripts
{
    <link rel="stylesheet" href="//code.jquery.com/ui/1.11.1/themes/smoothness/jquery-ui.css">
    <script src="~/Scripts/jquery.validate.js"></script>
    <script src="~/Scripts/jquery-ui-1.11.1.js"></script>
    <script src="~/Scripts/knockout-3.2.0.js"></script>
    <script src="~/Scripts/knockout.mapping-latest.js"></script>
    <script src="~/Scripts/moment.js"></script>

    <link href="~/Content/select2.css" rel="stylesheet" />
    <script src="~/Scripts/select2.js"></script>

    <script>
        $(document).ready(function () { $("#e1").select2(); });
    </script>



    <script src="~/Scripts/seasonsviewmodel.js"></script>
    <script type="text/javascript">
        var seasonViewModel = new SeasonViewModel(@Html.Raw(data));
        ko.applyBindings(seasonViewModel);
    </script>
}

@Html.Partial("_EditSeason")

Edit Partial View:

<h2>@ViewBag.Title</h2>

<p data-bind="text: MessageToClient"></p>

<div class="form-group">
    <label class="control-label" for="SeasonDescription">Season Description</label>
    <input class="form-control" name="SeasonDescription" id="SeasonDescription" data-bind="value: SeasonDescription, event: {change: flagSeasonAsEdited}, hasfocus: true" />
</div>
<div class="form-group">
    <label class="control-label" for="StartDate">Start Date</label>
    <input class="form-control" type="text" name="StartDate" id="StartDate" data-bind="datepicker: StartDate, datepickerOptions: {dateFormat: 'DD, MM d, yy'}, event: {change: flagSeasonAsEdited}" />
</div>
<div class="form-group">
    <label class="control-label" for="Publish">Publish</label>
    <input class="checkbox" name="Publish" id="Publish" type="checkbox" data-bind="checked: Publish, event: {change: flagSeasonAsEdited}" />
</div>

<table class="table table-striped">
    <tr>
        <th>Game Description</th>
        <th>Game Type</th>
        <th>Game Date</th>
        <th>Publish to Schedule</th>
        <th>Publish Results</th>
        <th><button data-bind="click: addGame" class="btn btn-info btn-xs">Add</button></th>
    </tr>
    <tbody data-bind="foreach: Games">
        <tr>
            <td class="form-group"><input class="form-control input-sm" data-bind="value: GameDescription, event: {change: flagGameAsEdited}, hasfocus: true" /></td>
            <td class="form-group"><select data-bind="options: $parent.gameTypes, optionsText: 'GameTypeDescription', optionsValue: 'Id', value: GameTypeId, select2: {  }, event: {change: flagGameAsEdited}"></select></td>
            <td class="form-group"><input class="form-control input-sm" type="text" data-bind="datepicker: GameDate, datepickerOptions: {dateFormat: 'DD, MM d, yy'}, event: {change: flagGameAsEdited}" /></td>
            <td class="form-group"><input class="checkbox" type="checkbox" data-bind="checked: PublishToSchedule, event: {change: flagGameAsEdited}"></td>
            <td class="form-group"><input class="checkbox" type="checkbox" data-bind="checked: PublishResults, event: {change: flagGameAsEdited}"></td>
            <td class="form-group"><button data-bind="click: $parent.deleteGame" class="btn btn-danger btn-xs">Delete</button></td>
        </tr>
    </tbody>
</table>


<p><button data-bind="click: save" class="btn btn-primary">Save</button></p>



<p><a href="/Admin/Seasons/" class="btn btn-default btn-sm">&laquo; Back to List</a></p>

Knockout View Model:

SeasonViewModel = function (data) {
    var self = this;

    ko.mapping.fromJS(data, gameMapping, self);

    self.save = function () {
        $.ajax({
            url: "/Seasons/Save/",
            type: "POST",
            data: ko.toJSON(self),
            contentType: "application/json",
            success: function (data) {
                // TODO: When re mapping the view model after save, all dates are in date2??
                if (data.seasonViewModel != null)
                    ko.mapping.fromJS(data.seasonViewModel, {}, self);

                if (data.newLocation != null)
                    window.location = data.newLocation;
            }
        });
    },

    self.flagSeasonAsEdited = function () {
        if (self.ObjectState() != ObjectState.Added) {
            self.ObjectState(ObjectState.Modified);
        }

        return true;
    },

    self.addGame = function () {
        // Game defaults
        var game = new GameViewModel({ Id: 0, GameDescription: "", GameTypeId: 1, GameDate: new Date(), PublishToSchedule: false, PublishResults: false, ObjectState: ObjectState.Added })
        self.Games.push(game);
    },

    self.deleteGame = function (game) {
        self.Games.remove(this);

        if (game.Id() > 0 && self.GamesToDelete.indexOf(game.Id()) == -1)
            self.GamesToDelete.push(game.Id());
    };
};

GameViewModel = function (data) {
    var self = this;

    ko.mapping.fromJS(data, gameMapping, self);

    self.flagGameAsEdited = function () {
        if (self.ObjectState() != ObjectState.Added) {
            self.ObjectState(ObjectState.Modified);
        }

        return true;
    };
};

GameTypeViewModel = function (data) {
    var self = this;

    ko.mapping.fromJS(data, gameTypeMapping, self);
    ko.applyBindings(new GameTypeViewModel());
};

var gameMapping = {
    'Games': {
        key: function (game) {
            return ko.utils.unwrapObservable(game.Id);
        },
        create: function (options) {
            return new GameViewModel(options.data);
        }
    }
};

var gameTypeMapping = {
    'gameTypes': {
        key: function (gameType) {
            return ko.utils.unwrapObservable(gameType.Id);
        },
        create: function (options) {
            return new GameTypeViewModel(options.data);
        }
    }
};

var ObjectState = {
    Unchanged: 0,
    Added: 1,
    Modified: 2,
    Deleted: 3
};

// Custom Knockout binding handler for date picker
ko.bindingHandlers.datepicker = {
    init: function (element, valueAccessor, allBindingsAccessor) {
        var options = allBindingsAccessor().datepickerOptions || {};
        $(element).datepicker(options);

        ko.utils.registerEventHandler(element, "change", function () {
            var observable = valueAccessor();
            observable($(element).datepicker("getDate"));
        });

        ko.utils.domNodeDisposal.addDisposeCallback(element, function () {
            $(element).datepicker("destroy");
        });

    },

    update: function (element, valueAccessor) {
        var value = new Date(ko.utils.unwrapObservable(valueAccessor()));
        var current = $(element).datepicker("getDate");

        // Prevents the datepicker from popping up a second time
        if (value - current !== 0) {
            // For some stange reason, Chrome subtracts a day when first displaying the date
            if (navigator.userAgent.indexOf('Chrome') > -1 && value != '') {
                var date = value.getDate() + 1;
                var month = value.getMonth();
                var year = value.getFullYear();
                value = new Date(year, month, date);
            };
            $(element).datepicker("setDate", value);
        }
    }
};

// Custom knockout binding handler for read-only date display
ko.bindingHandlers.dateString = {
    update: function (element, valueAccessor, allBindingsAccessor, viewModel) {
        var value = valueAccessor();
        var allBindings = allBindingsAccessor();
        var pattern = allBindings.datePattern || 'dd/MM/yyyy';
        var momentObj = moment(ko.utils.unwrapObservable(value), moment.ISO_8601);
        $(element).text(momentObj.format(pattern));
    }
};

ko.bindingHandlers.select2 = {
    init: function (element, valueAccessor, allBindingsAccessor) {
        //    var options = ko.toJS(valueAccessor()) || {};
        //    setTimeout(function () {
        //        $(element).select2(options);
        //    }, 0);
        //}
        var obj = valueAccessor(),
        allBindings = allBindingsAccessor(),
        lookupKey = allBindings.lookupKey;
        $(element).select2(obj);
        if (lookupKey) {
            var value = ko.utils.unwrapObservable(allBindings.value);
            $(element).select2('data', ko.utils.arrayFirst(obj.data.results, function (item) {
                return item[lookupKey] === value;
            }));
        }
    }
};

Can anyone shine any light as to why this behavior would occur?

Thanks for taking the time to read this post.

Paul.

paulpitchford
  • 550
  • 5
  • 24
  • The purpose of `window.location = data.newLocation` is unclear but I guess after that page reload the datepickers are just defaulting to the current date. Either remove it or check the bindings after that point. – Mark Wade Dec 29 '14 at 23:09
  • Thanks Mark, I'm following a Pluralsight course (watched and followed to start now trying my own models) and I am not 100% sure why that code is there myself. I commented it out and I still get the same behavior. I presumed that `if (data.seasonViewModel != null) ko.mapping.fromJS(data.seasonViewModel, {}, self);` would rebind the controls accordingly? One thing I did notice as per the comment is that the dates come back as date2. Maybe this could be the issue? – paulpitchford Dec 29 '14 at 23:17
  • 1
    Woa, that's quite a lot of code in your question. Any chance you could trim it down to a more minimal repro? – Jeroen Dec 29 '14 at 23:25
  • Sure, I can try and come back to it tomorrow and do that. The reason I posted all of it is I'm learning as I go and didn't want to miss anything that could be causing the issue. I appreciate it adds complexities to suggesting an answer but was hoping it would actually help someone help me solve the problem. – paulpitchford Dec 29 '14 at 23:27

1 Answers1

1

It is hard to tell without seeing your controller method to see if this is the exact problem but i had a similar issue on a project i was working on.

It had to do with how the MVC default JSON formatter works as opposed to the JSON.NET JsonConvert.SerializeObject.

In the success function on your ajax call you access data.seasonViewModel (which i assume is a potentially manipulated copy of the data set passed to the "/Seasons/Save/" call) you are probably getting back MVC's default serialized date format for all of your dates (Which looks something like "/Date(1239018869048)/"). So your knockout model is being initialized with this value on return instead of the more usable format that is created by JsonConvert.SerializeObject when you first load the page.

Now when the update function of the custom datepicker binding goes to pull this value out of the observable it uses new Date(ko.utils.unwrapObservable(valueAccessor())) which will try to create a date from the MVC default serialized date format, and fail.

If this is the case the solution would be to switch MVC's default JSON formatter to use JSON.NET or create a JsonResult that serializes using JSON.NET for these calls.

Couple Examples:

How do I sub in JSON.NET as model binder for ASP.NET MVC controllers?

Using JSON.NET as the default JSON serializer in ASP.NET MVC 3 - is it possible?

Setting the Default JSON Serializer in ASP.NET MVC

Community
  • 1
  • 1
Jarga
  • 391
  • 3
  • 9
  • This is exactly what is occurring. The posts you point me to help to an extent but JSON.Net seems to be losing my child objects once they hit the save controller action. I implemented the JsonDotNetValueProviderFactory and added ValueProviderFactories.Factories.Remove(ValueProviderFactories.Factories.OfType().FirstOrDefault()); ValueProviderFactories.Factories.Add(new JsonDotNetValueProviderFactory()); to Global.asx. I'll need to look into this further by the looks of it. Thanks for the help so far though, it's at least let me see where the problem lies. – paulpitchford Jan 03 '15 at 12:12
  • I managed to find a work around. What do you think to this? in the save action controller method I replaced the last line (return) with this: `var json = Newtonsoft.Json.JsonConvert.SerializeObject(seasonViewModel); return Json(new { json });` This seems to be working fine for me. Do you think there were be any problems using this code going forward? Thank you. – paulpitchford Jan 03 '15 at 12:29
  • That will work, but it might be better to wrap that up in a custom JsonResult for reusability's sake like this answer shows: http://stackoverflow.com/a/7150912/3900634. That way you don't have JsonConvert calls scattered across a bunch of controllers. It's a matter of preference at this point though. – Jarga Jan 04 '15 at 16:30
  • Thank you. I've marked you answer as the correct one. – paulpitchford Jan 04 '15 at 19:22