ASP.NET MVC DisplayTemplate and EditorTemplates for Entity Framework DbGeography Spatial Types
UPDATE: Be sure to read the whole post including the refactoring update at the end via a Pull Request from Dave Ward.
I was trying to write a blog post about something totally different and was using this small EntityFramework 5 Code First model:
public class TouristAttraction
{
public int TouristAttractionId { get; set; }
public string Name { get; set; }
public DbGeography Location { get; set; }
}
You'll notice I'm using the DbGeography spatial type with Latitude and Longitude. This support is new in Entity Framework 5. For a more complex example I might want to make a custom type of my own or split things up into a double lat and double long, but for this little app I just wanted a few fields and a map.
However, when I was scaffolding things out, of course, DbGeography was left in the cold. It wasn't scaffolded or included. When I added a map, there was no model binder. When I started making more complex views, there was no EditorFor or DisplayFor template support. So, rather than finishing the thing I was trying to do (I'll finish that project later) I became slightly obsessed focused with getting some kind of basic system working for DbGeography.
First, I scaffolded out a standard Entity Framework controller for "TouristAttraction" and changed nothing. The goal was to change nothing because DbGeography should just be treated like any other complex type. My prototype should be easily changeable for any other database or map system. There shouldn't be any Controller hacks to get it to work.
There's basically a DisplayTemplate and an EditorTemplate to show and edit maps. There's a model binder to handle DbGeography, and a small helper extension to get the client id for something more easily.
There's also some supporting JavaScript that I'm sure Dave Ward will hate because I'm still learning how to write JavaScript the way the kids write it today. Refactoring is welcome.
Creating and Editing
When creating a TouristAttraction you type the name then click the map. Clicking the map puts the lat,long in a text box (that could be hidden, of course).
I simply added a Google Map to the Layout:
<script type="text/javascript" src="http://maps.google.com/maps/api/js?sensor=false"></script>
then on the Create.cshml used an EditorFor just like any other field:
<fieldset>
<legend>TouristAttraction</legend>
<div class="editor-label">
@Html.LabelFor(model => model.Name)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.Name)
@Html.ValidationMessageFor(model => model.Name)
</div>
<div class="editor-label">
@Html.LabelFor(model => model.Location)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.Location)
</div>
<p>
<input type="submit" value="Create" />
</p>
</fieldset>
There is an EditorTemplate called DbGeography, the name of the type by convention. If the type is "Foo" and the file is EditorTemplates/Foo.cshtml or DisplayTemplates/Foo.cshtml you can use EditorFor() and DisplayFor() and the whole app gets the benefits.
@model System.Data.Spatial.DbGeography
@Html.TextBox("")
@if (Model != null) {
<script>
$(function () {
maps.markerToSet = new google.maps.LatLng(@Model.Latitude, @Model.Longitude);
});
</script>
}
@{ string textbox = Html.ClientIdFor(model => model).ToString(); }
<div id="map_canvas" data-textboxid="@textbox" style="width:400px; height:400px"></div>
This EditorTemplate needs to support Create as well as Edit so if Model isn't null it will set a variable that will be used later when the map is initialized in an Edit scenario.
Next steps would be to make a generated map id for the div so that I could have multiple maps on one page. I'm close here as I'm sticking the "friend" textbox id in a data- attribute so I don't have to have any JavaScript on this page. All the JavaScript should figure out names unobtrusively. It's not there yet, but the base is there. I should also put the width and height elsewhere.
You'll notice the call to ClientIdFor. At this point I want the name of the textbox's client id but I don't know it. I don't want to make an EditorTemplate that only works hard-coded for one type so I need to get the generated value. I have an HtmlHelper extension method:
public static partial class HtmlExtensions
{
public static MvcHtmlString ClientIdFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression)
{
return MvcHtmlString.Create(
htmlHelper.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId(ExpressionHelper.GetExpressionText(expression)));
}
}
This won't work unless you are sure to add your HtmlExtensions full namespace to the web.config in the Views folder under namespaces. That will let Razor see your HtmlHelper Extension method.
NOTE: Make sure you add a reference to System.Data.Entity to your MVC View's web.config in order to have a @model like I have above.
<compilation ... >
<assemblies>
<add assembly="System.Data.Entity, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"/>
</assemblies>
</compilation>
When you click the map it fills out the text box. I've also got some initial values in here as well as the addListenerOnce which is used later in DisplayFor to load a read-only" map when viewing a record's details.
function maps() { }
maps.mapInstance = null;
maps.marker= null;
maps.mapInstanceId = "map_canvas";
maps.markerToSet = null;
function initialize() {
var latlng = new google.maps.LatLng(40.716948, -74.003563); //a nice default
var options = {
zoom: 14, center: latlng,
mapTypeId: google.maps.MapTypeId.ROADMAP,
maxZoom: 14 //so extents zoom doesn't go nuts
};
maps.mapInstance = new google.maps.Map(document.getElementById(maps.mapInstanceId), options);
google.maps.event.addListener(maps.mapInstance, 'click', function (event) {
placeMarker(event.latLng);
});
google.maps.event.addListenerOnce(maps.mapInstance, 'idle', function (event) {
if (maps.markerToSet) {
placeMarker(maps.markerToSet);
var bound = new google.maps.LatLngBounds();
bound.extend(maps.markerToSet);
maps.mapInstance.fitBounds(bound);
}
});
}
function placeMarker(location) {
if (maps.marker) {
maps.marker.setPosition(location);
} else {
maps.marker = new google.maps.Marker({
position: location,
map: maps.mapInstance
});
}
if (maps.marker) { //What's a better way than this dance?
var textboxid = $("#" + maps.mapInstanceId).data("textboxid");
$("#" + textboxid).val(maps.marker.getPosition().toUrlValue(13));
}
}
$(function () {
initialize();
});
When I've filled out a new location and hit SAVE the lat,long is POSTed to the controller which I want to "just work" so I made a Model Binder to handle the DbGeography type.
I add it (actually its provider in case I add to it with other Entity Framework types) to the ModelBinderProviders collection in Global.asax:
ModelBinderProviders.BinderProviders.Add(new EFModelBinderProvider());
The ModelBinder itself tries to be generic as well. I must say I see FAR too many CustomModelBinders out there with folks calling into Request.Form digging for custom strings. That's not reusable and it's just wrong.
public class DbGeographyModelBinder : DefaultModelBinder
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
string[] latLongStr = valueProviderResult.AttemptedValue.Split(',');
string point = string.Format("POINT ({0} {1})",latLongStr[1], latLongStr[0]);
//4326 format puts LONGITUDE first then LATITUDE
DbGeography result = valueProviderResult == null ? null :
DbGeography.FromText(point,4326);
return result;
}
}
public class EFModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(Type modelType)
{
if (modelType == typeof(DbGeography))
{
return new DbGeographyModelBinder();
}
return null;
}
}
At this point, DbGeography is model bound and I don't have to change the controller. Now, again, I am NOT using a ViewModel here but I would on a larger application so be aware. I'd likely make a custom model binder for that hypothetical ViewModel and it would to be married to a database technology like this one is.
You can see in the database that I've got the points stored as "geography" types and they are round tripping just fine.
I threw this ASP.NET MVC 4 sample up on GitHub in https://github.com/shanselman/ASP.NET-MVC-and-DbGeography. You'll need VS2012 to play.
Again, please forgive my 3am hacking and poor JavaScript but I hope you get the idea and find it useful. I think this has the potential to be packaged up into a NuGet or perhaps useful as an EntityFramework and ASP.NET MVC sample.
IMPORTANT NOTE: In order to save lots of space space with samples I didn't check in binaries or include the packages folder with dependencies. Make sure that you have given NuGet permission to download missing packages during a build if you want to build this sample.
I've enabled NuGet Package Restore in the project by right clicking on the Solution node and clicking "Enable NuGet Package Restore." This will cause NuGet to download the missing packages on build, so be aware as there will be a pause on the first build when NuGet updates the package.
UPDATE #1
My buddy Dave Ward took a look at the code and submitted a pull request that improves some of the JavaScript and makes it more the way "the kids are writing JavaScript these days."
Dave has finished up a number of things for me. First, he's made things generic enough so one can have multiple maps on a page. He has also tidied up my JavaScript.
He starts by removing any inline script from the DisplayTemplats and EditorTemplates and instead uses the TextBox itself to hold the data. This, of course, is just one of several "duh" moments for me and a reminder that using HTML elements to hold data is kind of what they're for. It also brings up an interesting debate about views and models. I did think about using KnockoutJS for this but thought it overkill.
@model System.Data.Spatial.DbGeographyDave also introduces CSS classes as markers for the text boxes to identify them as editors for a DbGeography. That's editors, plural, since the primary limitation of my implementation was that there could be only one.
@if (Model != null) {
@Html.TextBox("", Model.Latitude + "," + Model.Longitude, new { @class = "editor-for-dbgeography" })
} else {
@Html.TextBox("", "", new { @class = "editor-for-dbgeography" })
}
Dave now keys off the text boxes rather than a div. He initializes any of these text boxes (for display or editor) and dynamically adds the Google Map after each.
$('.editor-for-dbgeography, .display-for-dbgeography').each(initialize);
He pulls the lat,long out of the text box and creates the map. When the text box changes he set a marker and pans the map to the new location.
It's surprisingly readable.
(function() {
// Method signature matching $.fn.each()'s, for easy use in the .each loop later.
var initialize = function(i, el) {
// el is the input element that we need to initialize a map for, jQuery-ize it,
// and cache that since we'll be using it a few times.
var $input = $(el);
// Create the map div and insert it into the page.
var $map = $('<div>', {
css: {
width: '400px',
height: '400px'
}
}).insertAfter($input);
// Attempt to parse the lat/long coordinates out of this input element.
var latLong = parseLatLong(this.value);
// If there was a problem attaining a lat/long from the input element's value,
// set it to a sensible default that isn't in the middle of the ocean.
if (!latLong || !latLong.latitude || !latLong.longitude) {
latLong = {
latitude: 40.716948,
longitude: -74.003563
};
}
// Create a "Google(r)(tm)" LatLong object representing our DBGeometry's lat/long.
var position = new google.maps.LatLng(latLong.latitude, latLong.longitude);
// Initialize the map widget.
var map = new google.maps.Map($map[0], {
zoom: 14,
center: position,
mapTypeId: google.maps.MapTypeId.ROADMAP,
maxZoom: 14
});
// Place a marker on it, representing the DBGeometry object's position.
var marker = new google.maps.Marker({
position: position,
map: map
});
var updateMarker = function(updateEvent) {
marker.setPosition(updateEvent.latLng);
// This new location might be outside the current viewport, especially
// if it was manually entered. Pan to center on the new marker location.
map.panTo(updateEvent.latLng);
// Black magic, courtesy of Hanselman's original version.
$input.val(marker.getPosition().toUrlValue(13));
};
// If the input came from an EditorFor, initialize editing-related events.
if ($input.hasClass('editor-for-dbgeography')) {
google.maps.event.addListener(map, 'click', updateMarker);
// Attempt to react to user edits in the input field.
$input.on('change', function() {
var latLong = parseLatLong(this.value);
latLong = new google.maps.LatLng(latLong.latitude, latLong.longitude);
updateMarker({ latLng: latLong });
});
}
};
var parseLatLong = function(value) {
if (!value) { return undefined; }
var latLong = value.match(/-?\d+\.\d+/g);
return {
latitude: latLong[0],
longitude: latLong[1]
};
};
// Find all DBGeography inputs and initialize maps for them.
$('.editor-for-dbgeography, .display-for-dbgeography').each(initialize);
})();
The next step will be to bundle this all up in a NuGet. Any ideas on a clear name? AspNet.Net.Mvc.SpatialTemplates?
About Scott
Scott Hanselman is a former professor, former Chief Architect in finance, now speaker, consultant, father, diabetic, and Microsoft employee. He is a failed stand-up comic, a cornrower, and a book author.
About Newsletter
Entity framework 5 & SQL server 2012 spatial features are independant ?
Anyone have any idea if and when support for hierarchy type is coming?
Am I doing something wrong? Is it not enough to check the "Allow NuGet to download....".
Last week I wanted to do the same on azure
Unfortunately DbGeography is only available in 4.5 and 4.5 hasn't released to azure yet :(
I've done a very crude tweak some people may find useful (I'm sure there is a better way of doing it, but this was my first method, and it worked, so it'll do for the moment).
On my db, it calculates an approximate location from a postcode (UK equiv of zip) using Google geolocater, and that Lat/Lng is already in the db. As such, I wanted to offer your editor some lat/lng data as an OPTIONAL value to give the editor a location for people to improve.
so for the view
@if (Model.Location == null)
{
// no location specified, so provide the approxLatLng
@Html.EditorFor(model => model.Location, new { approxLatLng = Model.ApproxLatitude.ToString() + "," + Model.ApproxLongitude.ToString() })
}else
{
// location available, use standard editor using dbGeog
@Html.EditorFor(model => model.Location)
}
and for the DbGeography EditorFor View
@if (Model != null)
{
@Html.TextBox("", Model.Latitude + "," + Model.Longitude, new { @class = "editor-for-dbgeography" })
}
else if (ViewData["approxLatLng"] != null)
{
@Html.TextBox("", ViewData["approxLatLng"], new { @class = "editor-for-dbgeography" })
}
else
{
@Html.TextBox("", "", new { @class = "editor-for-dbgeography" })
}
@model System.Data.Spatial.DbGeography
@Html.TextBox(string.Empty, this.Model == null ? string.Empty : this.Model.Latitude + "," + this.Model.Longitude, new { @class = "editor-for-dbgeography" })
/*** JS goes here ***/
I'm having trouble with the above. The templates are working fine but when I do a post to save, the model doesn't have a value for the Location. It's just null.
In the below piece of code valueProviderResult is always null...
public object BindModel(ControllerContext controllerContext, MvcModelBindingContext bindingContext)
{
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
return BindModelImpl(valueProviderResult != null ? valueProviderResult.AttemptedValue : null);
}
I'm completely stumped as to why this isn't happening. Also, the DbGeographyModelBinder has stopped getting called completely now for some reason!?
Thanks in advance for any help you can give me.
Mark
Thanks for the great snippet, Scott!
It turns out the MVC Git sample suffers from the same problem. Same test results as my prior comment states.
I'm stumped. FYI, the default MVC Route starts with "/MVC", it took me a few days to notice that. And the WebForms version works fine.
For people wanting to get to grips with DisplayFor and EditorFor templates check out my blog post:
http://dalsoft.co.uk/blog/index.php/2010/04/26/mvc-2-templates/
I'm unable to directly access the database, I can see it in VS2012 server explorer, but not expand it - permission denied. Forgive my ignorance, don't use local DB's much. Any insight? Thanks.
very interesting stuff - I'm extending it to also provide GeoCoding from the Name textbox, so you can input an address fragment, get a set of markers, click one of them and have the full address in the Name box and the Lat,Lng in the location box.
All works fine in the Create view, but in the Edit view it does not. The location Lat,Lng is not saved in the DB, and the field becomes Null!
Digging a little deeper, DbGeographyModelBinder.BindModelImpl is not called when Save is clicked in the Edit view, while it is from the Create Save.
So it seems something is a miss with the binding, but I'm not sure what.
Organic, have you been able to solve the problem you reported? This seems related.
BTW, this behavior is manifest in the Git download, with non of my changes.
Guidance will be appreciated!
The problem was in DbGeography.cshtml
@Html.TextBox("", Model.Latitude.Value.ToString("G",CultureInfo.InvariantCulture) + "," + Model.Longitude.Value.ToString("G",CultureInfo.InvariantCulture), new { @class = "editor-for-dbgeography",
What was the idea behind disabling the attribute? Or is it a bug.
Anyway, bot Create and Edit work fine now!
HTTP Error 403.14 - Forbidden
Why is this and other project are running correctly and i am using visual studio 2012
The type 'System.Data.Objects.ObjectContext' is defined in an assembly that is not referenced. You must add a reference to assembly 'System.Data.Entity, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089'. K:\Projects\DemoProject\Demo.DbProvider\Data\DbResourceContext.cs 13 16 Demo.DbProvider
Even after adding
<compilation debug="true" targetFramework="4.0">
<assemblies>
<add assembly="System.Data.Entity, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"/>
</assemblies>
</compilation>
To my web.config file.
This is separate project just a class library
Any help will be highly appropriated
Thanks
As this was class library i have to add the reference of system.data.entity manually that resolved the issue
if (string.IsNullOrEmpty(value))
and instead of registering the assembly in views/web.config, added
@using System.Data.Entity.Spatial
@model DbGeography (replaces @model System.Data.Spatial.DbGeography since in EF6 it moved)
to the top of the templates.
Looks like it's working, probably have to fix style class for Bootstrap 3.
Great work, Scott et al.
Comments are closed.