I've started playing with the Single Page Application template for MVC 5 that comes with Visual Studio 2013. I'm more than familiar with Knockout.js
, and although I wasn't with Sammy.js
I've been reading up on it and it doesn't seem all that complicated.
What I can't seem to wrap my head around is how the MVC 5 SPA Template combines these technologies, or what the Visual Studio team had in mind for the template as an example; the template provides, amongst other things, a home.viewModel.js
file that's supposed to serve as a starting point, but I can't seem to understand how I can add more views with Sammy.js
routes. If only they had provided a second partial view and viewmodel.
So, short story long, my real questions are,
#users
in a way that mimics the provided home.viewmodel.js
, so that I can navigate back a forth from #home
to #users
? What would the Sammy.js
route definition look like in users.viewModel.js
?The following code is just for extra reference/context, but it probably not necessary in order for the question to be answered.
Let's assume I have created a partial view, _Users.cshtml
, served by a UserController
, which is an MVC controller and not a WebAPI
controller, and that I want to display that partial view by means of a Sammy.js
route, to which end I've created a users.viewModel.js
. Now...
The provided Index.cshtml
view looks like this:
@section SPAViews {
@Html.Partial("_Home")
}
@section Scripts{
@Scripts.Render("~/bundles/knockout")
@Scripts.Render("~/bundles/app")
}
Which I presume is meant as the application "shell" page, where the rest of partial views will be loaded to substitute the contents of the _Home
partial.
The problem is that on the home.viewmodel.js
the Sammy
route is initialized without passing in a selector for the element that will hold the content, like this
Sammy(function () {
this.get('#home', function () {
// more code here
}
instead of, for example
Sammy("#content", function () {
this.get('#home', function () {
// more code here
}
Am I supposed to place the _Users
partial alongside _Home
from the very beginning so that the Index
view looks like this?
@section SPAViews {
@Html.Partial("_Home")
@Html.Partial("_Users")
}
@section Scripts{
@Scripts.Render("~/bundles/knockout")
@Scripts.Render("~/bundles/app")
}
This will, of course, display both views at the same time, which is not what we want.
My users.viewmodel.js
looks like this:
function UsersViewModel(app, dataModel) {
var self = this;
Sammy(function () {
this.get('#users', function () {
// the following line only makes sense if _Users is not
// called from Index.cshtml
//this.load(app.dataModel.shoppingCart).swap();
});
});
return self;
}
app.addViewModel({
name: "Users",
bindingMemberName: "users",
factory: UsersViewModel
});
I've tried using the Sammy.js
swap
method, but since my _Users
view is a partial and Sammy
is not set up to act on a specific element the whole page is replaced... and the browser's back button doesn't seem to work.
Sorry for the massive amount of text, and if this is a very trivial question. It bothers me that I can't seem to figure it out on my own, even after going through the docs.
Stumbling upon this myself I managed to apply my own little 'hack' to fix this.
When comparing the 'older' template with the new one, I noticed Sammy.js
is more embedded in the template. Although this is a good thing, the original out-of-the box's knockout with
binding to show your views is broken.
To apply the fix, first of all it is required to understand the knockouts with
binding. In the default home
view there is the
<!-- ko with: home-->
statement which should ensure visibility of the home view
only when a member home
is present. In this case the full name would be app.home
If we inspect this members name, we see it is a computed member defined such as (app.viewmodel.js):
// Add binding member to AppViewModel (for example, app.home);
self[options.bindingMemberName] = ko.computed(function () {
if (!dataModel.getAccessToken()) {
//omitted for clearity
if (fragment.access_token) {
//omitted for clearity
} else {
//omitted for clearity
}
}
return self.Views[options.name];
});
As you can see, it always returns a full initialized view from the Views
collection.
If we compare this with the older template we can see a change here:
// Add binding member to AppViewModel (for example, app.home);
self[options.bindingMemberName] = ko.computed(function () {
if (self.view() !== viewItem) {
return null;
}
return new options.factory(self, dataModel);
});
Which returns null if the current view is not the targeted viewItem
. This is crucial for the knockout's with
binding.
Further inspection of both templates shows the better integration with sammy.js
. A crucial part of it lies in the viewmodels (home.viewmodel.js):
Sammy(function () {
this.get('#home', function () {
});
this.get('/', function () { this.app.runRoute('get', '#home') });
});
Since sammy.js
is handling the navigation, the earlier mentioned viewItem
, encapsulated in app.view()
is not set. Which, again is crucial for the knockout binding.
So, my proposed fix is as follows:
app.viewmodel.js
// Add binding member to AppViewModel (for example, app.home);
self[options.bindingMemberName] = ko.computed(function () {
if (!dataModel.getAccessToken()) {
//omitted for clearity
if (fragment.access_token) {
//omitted for clearity
} else {
//omitted for clearity
}
}
///change start here
if (self.view() !== viewItem) {
return null;
}
return self.Views[options.name];
});
and in every custom viewmodel:
home.viewmodel.js
Sammy(function () {
this.get('#home', function () {
app.view(self); //this line is added
});
this.get('/', function () { this.app.runRoute('get', '#home') });
});
Disclaimer: Since I just got this up and running, I didn't have the time to analyze any unwanted side affects. Besides, altering the default template's core doesn't feel very satisfiable, so better solutions are welcome.
This will, of course, display both views at the same time, which is not what we want.
Actually, in many cases this is exactly what you want (or, rather, you want their presence and to control their visibility.) In addition to a visibility property on the viewmodel and some JS helper methods (or class) to show/hide your views (via the viewmodel references, typically associated with a particular url as well.)
Pseudo _Home.cshtml:
<!-- ko with: $root.home -->
<div data-bind="visible: isVisible">
<!-- view markup/etc here -->
</div>
<!-- /ko -->
Pseudo: app.viewmanager.js
MyViewManager = function () {
this.registerView = function(route, selector, viewmodel) {/**/};
this.showView = function(selector, callback) {};
this.cancelView = function(callback) {/**/};
this.showModal = function(selector, callback) {/**/};
this.closeModal = function(selector, callback) {/**/};
}
These would handle integrating with History API
for routing/deep-linking, and knockout to show/hide DOM elements (via the IsVisible binding). The above 'registerView' would replace addViewModel
from the default scaffold, of course. All of that, IMO, is trash.
I've been developing SPAs on top of the MVC framework for several years. The MVC5 SPA template is a nice show of interest, but it has problems. Proper deep-linking, viewmodel initialization and view management are the more obvious issues, but with a bit of elbow grease you can code what you need easily.
I also find the SPAViews
section useless, and prefer to use RenderBody for partial delivery, which requires some modification of _Layout.cshtml
. After all, for a large enough SPA you will wind up delivering almost all of your primary views in a single Page/View anyway (it's rare to see Ajax partials in an SPA, even a large one.) And the only value SPAViews
section provides is placement within the _Layout, effectively duplicating the function of RenderBody() (since the body of your SPA is always going to be a collection of invisible views.)
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With