Published articles on other web sites*

Published articles on other web sites*

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; }

}

The little bit I added


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).


Creating a location with a clickable Google Map


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.


Entity Framework v5 Code First and VS2012 support for SQL Server's geography types


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.


Be sure to click "Allow NuGet to download missing packages during build"


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.DbGeography

@if (Model != null) {

@Html.TextBox("", Model.Latitude + "," + Model.Longitude, new { @class = "editor-for-dbgeography" })

} else {

@Html.TextBox("", "", new { @class = "editor-for-dbgeography" })

}

Dave 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.

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?






© 2012 Scott Hanselman. All rights reserved.






via Scott Hanselman http://www.hanselman.com/blog/ASPNETMVCDisplayTemplateAndEditorTemplatesForEntityFrameworkDbGeographySpatialTypes.aspx?utm_source=feedburner&utm_medium=feed&utm_campaign=Feed%3A+ScottHanselman+%28Scott+Hanselman+-+ComputerZen.com%29 Scott Hanselman

No comments:

Post a Comment

Related Posts Plugin for WordPress, Blogger...