Compartir a través de


Usar AJAX para implementar escenarios de asignación

por Microsoft

Descargar PDF

Este es el paso 11 de un tutorial de la aplicación "NerdDinner" gratuito que le guía durante el proceso de compilación de una aplicación web pequeña, pero completa, con ASP.NET MVC 1.

En el paso 11 se muestra cómo integrar la compatibilidad con la asignación de AJAX en nuestra aplicación NerdDinner, lo que permite a los usuarios que crean, editan o ven comidas ver gráficamente la ubicación de la comida.

Si utiliza ASP.NET MVC 3, le recomendamos que siga los tutoriales Introducción a MVC 3 o Tienda de música de MVC.

NerdDinner Paso  11: Integración de un mapa de AJAX

Ahora haremos que nuestra aplicación sea un poco más interesante visualmente mediante la integración de la compatibilidad con la asignación de AJAX. Esto permitirá a los usuarios que crean, editan o ven comidas ver la ubicación de la comida gráficamente.

Creación de una vista parcial de mapa

Vamos a usar la funcionalidad de asignación en varios lugares dentro de nuestra aplicación. Para mantener el código DRY, encapsularemos la funcionalidad de mapa común dentro de una sola plantilla parcial que podemos volver a usar en varias acciones y vistas del controlador. Denominaremos esta vista parcial "map.ascx" y la crearemos en el directorio \Views\Dinners.

Podemos crear el elemento map.ascx parcial haciendo clic con el botón derecho en el directorio \Views\Dinners y seleccionando el comando de menú Agregar->Vista. La vista le asignaremos el nombre "Map.ascx", lo comprobaremos como una vista parcial e indicaremos que se va a pasar una clase de modelo "Dinner" fuertemente tipada:

Screenshot of the Add View dialog. Nerd Dinner dot Models dot Dinner is written in the View data class box.

Al hacer clic en el botón "Agregar", se creará la plantilla parcial. A continuación, actualizaremos el archivo Map.ascx para que tenga el siguiente contenido:

<script src="http://dev.virtualearth.net/mapcontrol/mapcontrol.ashx?v=6.2" type="text/javascript"></script>
<script src="/Scripts/Map.js" type="text/javascript"></script>

<div id="theMap">
</div>

<script type="text/javascript">
   
    $(document).ready(function() {
        var latitude = <%=Model.Latitude%>;
        var longitude = <%=Model.Longitude%>;
                
        if ((latitude == 0) || (longitude == 0))
            LoadMap();
        else
            LoadMap(latitude, longitude, mapLoaded);
    });
      
   function mapLoaded() {
        var title = "<%=Html.Encode(Model.Title) %>";
        var address = "<%=Html.Encode(Model.Address) %>";
    
        LoadPin(center, title, address);
        map.SetZoomLevel(14);
    } 
      
</script>

La primera referencia de <script> apunta a la biblioteca de asignación de Microsoft Virtual Earth 6.2. La segunda referencia de <script> apunta a un archivo map.js que crearemos en breve, que encapsulará nuestra lógica de asignación de Javascript común. El elemento <div id="theMap"> es el contenedor HTML que usará Virtual Earth para hospedar el mapa.

A continuación, tenemos un bloque de <script> insertado con dos funciones de JavaScript específicas de esta vista. La primera función usa jQuery para conectar una función que se ejecuta cuando la página está lista para ejecutar el script del lado cliente. Llama a una función auxiliar LoadMap() que definiremos dentro de nuestro archivo de script de Map.js para cargar el control de mapa virtual de La Tierra. La segunda función es un controlador de eventos de devolución de llamada que agrega un pin al mapa que identifica una ubicación.

Observe cómo usamos un bloque <%= %> del lado servidor dentro del bloque de script del lado cliente para insertar la latitud y longitud de la comida que queremos asignar a JavaScript. Se trata de una técnica útil para generar valores dinámicos que el script del lado cliente puede usar (sin necesidad de una llamada AJAX independiente al servidor para recuperar los valores, lo que hace que sea más rápido). Los bloques <%= %> se ejecutarán cuando la vista se esté representando en el servidor, por lo que la salida del código HTML terminará con valores de JavaScript insertados (por ejemplo, latitud var = 47.64312;).

Creación de una biblioteca de utilidades de Map.js

Ahora vamos a crear el archivo Map.js para encapsular la funcionalidad de JavaScript para nuestro mapa (e implementar los métodos LoadMap y LoadPin anteriores). Para ello, haremos clic con el botón derecho en el directorio \Scripts del proyecto y elegiremos el comando de menú "Agregar->Nuevo elemento", seleccionaremos el elemento JScript y le asignaremos el nombre "Map.js".

A continuación se muestra el código JavaScript que agregaremos al archivo Map.js que interactuará con Virtual Earth para mostrar nuestro mapa y agregarle anclas de ubicaciones para nuestras comidas:

var map = null;
var points = [];
var shapes = [];
var center = null;

function LoadMap(latitude, longitude, onMapLoaded) {
    map = new VEMap('theMap');
    options = new VEMapOptions();
    options.EnableBirdseye = false;

    // Makes the control bar less obtrusize.
    map.SetDashboardSize(VEDashboardSize.Small);
    
    if (onMapLoaded != null)
        map.onLoadMap = onMapLoaded;

    if (latitude != null && longitude != null) {
        center = new VELatLong(latitude, longitude);
    }

    map.LoadMap(center, null, null, null, null, null, null, options);
}

function LoadPin(LL, name, description) {
    var shape = new VEShape(VEShapeType.Pushpin, LL);

    //Make a nice Pushpin shape with a title and description
    shape.SetTitle("<span class=\"pinTitle\"> " + escape(name) + "</span>");
    if (description !== undefined) {
        shape.SetDescription("<p class=\"pinDetails\">" + 
        escape(description) + "</p>");
    }
    map.AddShape(shape);
    points.push(LL);
    shapes.push(shape);
}

function FindAddressOnMap(where) {
    var numberOfResults = 20;
    var setBestMapView = true;
    var showResults = true;

    map.Find("", where, null, null, null,
           numberOfResults, showResults, true, true,
           setBestMapView, callbackForLocation);
}

function callbackForLocation(layer, resultsArray, places,
            hasMore, VEErrorMessage) {
            
    clearMap();

    if (places == null) 
        return;

    //Make a pushpin for each place we find
    $.each(places, function(i, item) {
        description = "";
        if (item.Description !== undefined) {
            description = item.Description;
        }
        var LL = new VELatLong(item.LatLong.Latitude,
                        item.LatLong.Longitude);
                        
        LoadPin(LL, item.Name, description);
    });

    //Make sure all pushpins are visible
    if (points.length > 1) {
        map.SetMapView(points);
    }

    //If we've found exactly one place, that's our address.
    if (points.length === 1) {
        $("#Latitude").val(points[0].Latitude);
        $("#Longitude").val(points[0].Longitude);
    }
}

function clearMap() {
    map.Clear();
    points = [];
    shapes = [];
}

Integración del mapa con los formularios de creación y edición

Ahora integraremos la compatibilidad de mapa con nuestros escenarios de creación y edición existentes. La buena noticia es que esto es bastante fácil y no requiere que cambiemos ninguno de nuestros códigos de controlador. Dado que nuestras vistas Crear y Editar comparten una vista parcial común de "DinnerForm" para implementar la interfaz de usuario del formulario de comida, podemos agregar el mapa en un solo lugar y hacer que los escenarios de creación y edición lo usen.

Todo lo que necesitamos hacer es abrir la vista parcial \Views\DinnerForm.ascx y actualizarla para incluir nuestro nuevo mapa parcial. A continuación se muestra el aspecto de DinnerForm actualizado una vez agregado el mapa (nota: los elementos de formulario HTML se omiten en el fragmento de código siguiente para mayor brevedad):

<%= Html.ValidationSummary() %>
 
<% using (Html.BeginForm()) { %>
 
    <fieldset>

        <div id="dinnerDiv">
            <p>
               [HTML Form Elements Removed for Brevity]
            </p>                 
            <p>
               <input type="submit" value="Save"/>
            </p>
        </div>
        
        <div id="mapDiv">    
            <%Html.RenderPartial("Map", Model.Dinner); %>
        </div> 
            
    </fieldset>

    <script type="text/javascript">

        $(document).ready(function() {
            $("#Address").blur(function(evt) {
                $("#Latitude").val("");
                $("#Longitude").val("");

                var address = jQuery.trim($("#Address").val());
                if (address.length < 1)
                    return;

                FindAddressOnMap(address);
            });
        });
    
    </script>

<% } %>

El elemento DinnerForm parcial anterior toma un objeto de tipo "DinnerFormViewModel" como tipo de modelo (porque necesita un objeto Dinner, así como SelectList para rellenar la lista desplegable de países). Nuestra asignación parcial solo necesita un objeto de tipo "Dinner" como tipo de modelo, por lo que cuando se representa el mapa parcial, se le pasa solo la subpropiedad Dinner de DinnerFormViewModel:

<% Html.RenderPartial("Map", Model.Dinner); %>

La función de JavaScript que hemos agregado al elemento parcial usa jQuery para adjuntar un evento de "desenfoque" al cuadro de texto HTML "Dirección". Probablemente haya oído hablar de eventos de "enfoque" que se desencadenan cuando un usuario hace clic o tabula en un cuadro de texto. Lo contrario es un evento de "desenfoque" que se desencadena cuando un usuario sale de un cuadro de texto. El controlador de eventos anterior borra los valores de latitud y longitud del cuadro de texto cuando esto sucede y traza la nueva ubicación de dirección en nuestro mapa. Un controlador de eventos de devolución de llamada que definimos dentro del archivo map.js actualizará los cuadros de texto de longitud y latitud en nuestro formulario mediante los valores devueltos por La Tierra virtual en función de la dirección que le dimos.

Y ahora, cuando volvamos a ejecutar nuestra aplicación y hagamos clic en la pestaña "Dar comida", veremos un mapa predeterminado que se muestra junto con nuestros elementos estándar del formulario Comida:

Screenshot of the Host Dinner page with a default map displayed.

Cuando se escribe una dirección y, a continuación, se elimina la tabulación, el mapa se actualiza dinámicamente para mostrar la ubicación y el controlador de eventos rellena los cuadros de texto de latitud y longitud con los valores de ubicación:

Screenshot of the Nerd Dinners page with a map displayed.

Si guardamos la nueva comida y a abrimos de nuevo para editarla, veremos que la ubicación del mapa se muestra cuando se carga la página:

Screenshot of the Edit page on the Nerd Dinners site.

Cada vez que se cambia el campo de dirección, el mapa y las coordenadas de latitud y longitud se actualizan.

Ahora que el mapa muestra la ubicación de la comida, también podemos cambiar los campos de formulario Latitud y Longitud para que dejen de ser visibles y sean elementos ocultos en su lugar (ya que el mapa los actualiza automáticamente cada vez que se escribe una dirección). Para ello, pasaremos de usar el asistente HTML Html.TextBox() a usar el método auxiliar Html.Hidden():

<p>
    <%= Html.Hidden("Latitude", Model.Dinner.Latitude)%>
    <%= Html.Hidden("Longitude", Model.Dinner.Longitude)%>
</p>

Y ahora nuestros formularios son un poco más fáciles de usar y evitan mostrar la latitud y longitud sin procesar (mientras se siguen almacenando con cada comida en la base de datos):

Screenshot of a map on the Nerd Dinners page.

Integración del mapa con la vista Detalles

Ahora que tenemos el mapa integrado con nuestros escenarios de creación y edición, también se integrará con nuestro escenario de detalles. Todo lo que necesitamos hacer es llamar a <% Html.RenderPartial("map"); %> dentro de la vista Detalles.

A continuación se muestra el aspecto del código fuente de la vista Detalles completa (con integración de mapa):

<asp:Content ID="Title" ContentPlaceHolderID="TitleContent"runat="server">
    <%= Html.Encode(Model.Title) %>
</asp:Content>

<asp:Content ID="details" ContentPlaceHolderID="MainContent" runat="server">

    <div id="dinnerDiv">

        <h2><%=Html.Encode(Model.Title) %></h2>
        <p>
            <strong>When:</strong> 
            <%=Model.EventDate.ToShortDateString() %> 

            <strong>@</strong>
            <%=Model.EventDate.ToShortTimeString() %>
        </p>
        <p>
            <strong>Where:</strong> 
            <%=Html.Encode(Model.Address) %>,
            <%=Html.Encode(Model.Country) %>
        </p>
         <p>
            <strong>Description:</strong> 
            <%=Html.Encode(Model.Description) %>
        </p>       
        <p>
            <strong>Organizer:</strong> 
            <%=Html.Encode(Model.HostedBy) %>
            (<%=Html.Encode(Model.ContactPhone) %>)
        </p>
    
        <%Html.RenderPartial("RSVPStatus"); %>
        <%Html.RenderPartial("EditAndDeleteLinks"); %>
 
    </div>
    
    <div id="mapDiv">
        <%Html.RenderPartial("map"); %>    
    </div>   
         
</asp:Content>

Y ahora cuando un usuario navega a una dirección URL /Dinners/Details/[id] verá los detalles de la comida, su ubicación en el mapa (completa con un pin de inserción que al mantener el puntero sobre ella muestra el título de la comida y su dirección), y tiene un vínculo AJAX a RSVP:

Screenshot of the Nerd Dinners web page. A map is shown.

Implementación de la búsqueda de ubicación en nuestra base de datos y repositorio

Para terminar nuestra implementación de AJAX, vamos a agregar un mapa a la página principal de la aplicación que permita a los usuarios buscar gráficamente comidas cerca de ellos.

Screenshot of the home page of Nerd Dinners. A map is shown.

Comenzaremos implementando la compatibilidad en nuestra capa de base de datos y repositorio de datos para realizar de forma eficaz una búsqueda de radio basada en ubicación para las comidas. Podríamos usar las nuevas características geoespaciales de SQL 2008 para implementar esto o, como alternativa, podemos usar un enfoque de función SQL que Gary Dryden ha descrito en este artículo: http://www.codeproject.com/KB/cs/distancebetweenlocations.aspx.

Para implementar esta técnica, abriremos el "Explorador de servidores" en Visual Studio, seleccionaremos la base de datos NerdDinner, haremos clic con el botón derecho en el subnodo "functions" debajo de él y elegiremos crear una nueva "función escalar":

Screenshot of the Server Explorer in Visual Studio. Nerd Dinner database is selected and the functions sub node is selected. Scalar Valued Function is highlighted.

A continuación, pegaremos la siguiente función DistanceBetween:

CREATE FUNCTION [dbo].[DistanceBetween](@Lat1 as real,
                @Long1 as real, @Lat2 as real, @Long2 as real)
RETURNS real
AS
BEGIN

DECLARE @dLat1InRad as float(53);
SET @dLat1InRad = @Lat1 * (PI()/180.0);
DECLARE @dLong1InRad as float(53);
SET @dLong1InRad = @Long1 * (PI()/180.0);
DECLARE @dLat2InRad as float(53);
SET @dLat2InRad = @Lat2 * (PI()/180.0);
DECLARE @dLong2InRad as float(53);
SET @dLong2InRad = @Long2 * (PI()/180.0);

DECLARE @dLongitude as float(53);
SET @dLongitude = @dLong2InRad - @dLong1InRad;
DECLARE @dLatitude as float(53);
SET @dLatitude = @dLat2InRad - @dLat1InRad;
/* Intermediate result a. */
DECLARE @a as float(53);
SET @a = SQUARE (SIN (@dLatitude / 2.0)) + COS (@dLat1InRad)
                 * COS (@dLat2InRad)
                 * SQUARE(SIN (@dLongitude / 2.0));
/* Intermediate result c (great circle distance in Radians). */
DECLARE @c as real;
SET @c = 2.0 * ATN2 (SQRT (@a), SQRT (1.0 - @a));
DECLARE @kEarthRadius as real;
/* SET kEarthRadius = 3956.0 miles */
SET @kEarthRadius = 6376.5;        /* kms */

DECLARE @dDistance as real;
SET @dDistance = @kEarthRadius * @c;
return (@dDistance);
END

Después, crearemos una nueva función con valores de tabla en SQL Server que llamaremos "NearestDinners":

Screenshot of the S Q L Server. Table-Valued function is highlighted.

Esta función de tabla "NearestDinners" usa la función auxiliar DistanceBetween para devolver todas las comidas en un radio de 100 millas de la latitud y longitud dado:

CREATE FUNCTION [dbo].[NearestDinners]
      (
      @lat real,
      @long real
      )
RETURNS  TABLE
AS
      RETURN
      SELECT Dinners.DinnerID
      FROM   Dinners 
      WHERE  dbo.DistanceBetween(@lat, @long, Latitude, Longitude) <100

Para llamar a esta función, primero abriremos el diseñador de LINQ to SQL haciendo doble clic en el archivo NerdDinner.dbml dentro de nuestro directorio \Models:

Screenshot of the Nerd Dinner dot d b m l file in the Models directory.

A continuación, arrastraremos las funciones NearestDinners y DistanceBetween al diseñador de LINQ to SQL, lo que hará que se agreguen como métodos en nuestra clase LINQ to SQL NerdDinnerDataContext:

Screenshot of the Nearest Dinners and Distance Between functions.

A continuación, podemos exponer un método de consulta "FindByLocation" en nuestra clase DinnerRepository que use la función NearestDinner para devolver las próximas comidas a menos de 100 millas de la ubicación especificada:

public IQueryable<Dinner> FindByLocation(float latitude, float longitude) {

   var dinners = from dinner in FindUpcomingDinners()
                 join i in db.NearestDinners(latitude, longitude)
                 on dinner.DinnerID equals i.DinnerID
                 select dinner;

   return dinners;
}

Implementación de un método de acción de búsqueda de AJAX basado en JSON

Ahora implementaremos un método de acción de controlador que aprovecha el nuevo método de repositorio FindByLocation() para devolver una lista de datos de Comida que se pueden usar para rellenar un mapa. Este método de acción devolverá los datos de Comida en formato JSON (notación de objetos JavaScript) para que se pueda manipular fácilmente mediante JavaScript en el cliente.

Para implementarlo, crearemos una nueva clase "SearchController" haciendo clic con el botón derecho en el directorio \Controllers y eligiendo el comando de menú Agregar->Controlador. A continuación, implementaremos un método de acción "SearchByLocation" dentro de la nueva clase SearchController como se indica a continuación:

public class JsonDinner {
    public int      DinnerID    { get; set; }
    public string   Title       { get; set; }
    public double   Latitude    { get; set; }
    public double   Longitude   { get; set; }
    public string   Description { get; set; }
    public int      RSVPCount   { get; set; }
}

public class SearchController : Controller {

    DinnerRepository dinnerRepository = new DinnerRepository();

    //
    // AJAX: /Search/SearchByLocation

    [AcceptVerbs(HttpVerbs.Post)]
    public ActionResult SearchByLocation(float longitude, float latitude) {

        var dinners = dinnerRepository.FindByLocation(latitude,longitude);

        var jsonDinners = from dinner in dinners
                          select new JsonDinner {
                              DinnerID = dinner.DinnerID,
                              Latitude = dinner.Latitude,
                              Longitude = dinner.Longitude,
                              Title = dinner.Title,
                              Description = dinner.Description,
                              RSVPCount = dinner.RSVPs.Count
                          };

        return Json(jsonDinners.ToList());
    }
}

El método de acción SearchByLocation de SearchController llama internamente al método FindByLocation en DinnerRepository para obtener una lista de comidas cercanas. Sin embargo, en lugar de devolver los objetos Dinner directamente al cliente, devuelve objetos JsonDinner. La clase JsonDinner expone un subconjunto de propiedades Dinner (por ejemplo: por motivos de seguridad no indica los nombres de las personas que tienen RSVP para una comida). También incluye una propiedad RSVPCount que no existe en Dinner y que se calcula dinámicamente contando el número de objetos RSVP asociados a una comida determinada.

A continuación, usamos el método auxiliar Json() en la clase base Controller para devolver la secuencia de comidas mediante un formato de conexión basado en JSON. JSON es un formato de texto estándar para representar estructuras de datos simples. A continuación se muestra un ejemplo de cómo es una lista con formato JSON de dos objetos JsonDinner cuando se devuelve de nuestro método de acción:

[{"DinnerID":53,"Title":"Dinner with the Family","Latitude":47.64312,"Longitude":-122.130609,"Description":"Fun dinner","RSVPCount":2}, 
{"DinnerID":54,"Title":"Another Dinner","Latitude":47.632546,"Longitude":-122.21201,"Description":"Dinner with Friends","RSVPCount":3}]

Llamada al método AJAX basado en JSON mediante jQuery

Ahora estamos listos para actualizar la página principal de la aplicación NerdDinner para usar el método de acción SearchByLocation de SearchController. Para ello, abriremos la plantilla de vista /Views/Home/Index.aspx y la actualizaremos para que tenga un cuadro de texto, un botón de búsqueda, nuestro mapa y un elemento <div> denominado dinnerList:

<h2>Find a Dinner</h2>

<div id="mapDivLeft">

    <div id="searchBox">
        Enter your location: <%=Html.TextBox("Location") %>
        <input id="search" type="submit" value="Search"/>
    </div>

    <div id="theMap">
    </div>

</div>

<div id="mapDivRight">
    <div id="dinnerList"></div>
</div>

A continuación, podemos agregar dos funciones de JavaScript a la página:

<script type="text/javascript">

    $(document).ready(function() {
        LoadMap();
    });

    $("#search").click(function(evt) {
        var where = jQuery.trim($("#Location").val());
        if (where.length < 1) 
            return;

        FindDinnersGivenLocation(where);
    });

</script>

La primera función de JavaScript carga el mapa cuando se carga la página por primera vez. La segunda función de JavaScript conecta un controlador de eventos de clic de JavaScript en el botón de búsqueda. Cuando se presiona el botón, llama a la función JavaScript FindDinnersGivenLocation() que agregaremos al archivo Map.js:

function FindDinnersGivenLocation(where) {
    map.Find("", where, null, null, null, null, null, false,
       null, null, callbackUpdateMapDinners);
}

Esta función FindDinnersGivenLocation() llama a map.Find() en el Control de La Tierra virtual para centrarlo en la ubicación especificada. Cuando se devuelve el servicio de mapa de La Tierra virtual, el método map.Find() invoca el método de devolución de llamada callbackUpdateMapDinners que se ha pasado como argumento final.

El método callbackUpdateMapDinners() es donde se realiza el trabajo real. Usa el método auxiliar $.post() de jQuery para realizar una llamada AJAX al método de acción SearchByLocation() de SearchController, pasando la latitud y longitud del mapa recién centrado. Define una función insertada a la que se llamará cuando se complete el método auxiliar $.post() y los resultados de comida con formato JSON devueltos por el método de acción SearchByLocation() se pasarán mediante una variable denominada "dinners". A continuación, realizará un foreach sobre cada comida devuelta, y usará la latitud y longitud de la comida y otras propiedades para agregar un nuevo pin en el mapa. También agregará una entrada de comida a la lista HTML de comidas a la derecha del mapa. A continuación, se conectará un evento de desplazamiento para los marcadores y la lista HTML para que se muestren detalles de la comida cuando un usuario mantenga el puntero sobre ella:

function callbackUpdateMapDinners(layer, resultsArray, places, hasMore, VEErrorMessage) {

    $("#dinnerList").empty();
    clearMap();
    var center = map.GetCenter();

    $.post("/Search/SearchByLocation", { latitude: center.Latitude, 
                                         longitude: center.Longitude },     
    function(dinners) {
        $.each(dinners, function(i, dinner) {

            var LL = new VELatLong(dinner.Latitude, 
                                   dinner.Longitude, 0, null);

            var RsvpMessage = "";

            if (dinner.RSVPCount == 1)
                RsvpMessage = "" + dinner.RSVPCount + "RSVP";
            else
                RsvpMessage = "" + dinner.RSVPCount + "RSVPs";

            // Add Pin to Map
            LoadPin(LL, '<a href="/Dinners/Details/' + dinner.DinnerID + '">'
                        + dinner.Title + '</a>',
                        "<p>" + dinner.Description + "</p>" + RsvpMessage);

            //Add a dinner to the <ul> dinnerList on the right
            $('#dinnerList').append($('<li/>')
                            .attr("class", "dinnerItem")
                            .append($('<a/>').attr("href",
                                      "/Dinners/Details/" + dinner.DinnerID)
                            .html(dinner.Title))
                            .append(" ("+RsvpMessage+")"));
        });

        // Adjust zoom to display all the pins we just added.
        map.SetMapView(points);

        // Display the event's pin-bubble on hover.
        $(".dinnerItem").each(function(i, dinner) {
            $(dinner).hover(
                function() { map.ShowInfoBox(shapes[i]); },
                function() { map.HideInfoBox(shapes[i]); }
            );
        });
    }, "json");

Y ahora, cuando ejecutamos la aplicación y visitamos la página principal, se mostrará un mapa. Cuando escribamos el nombre de una ciudad, el mapa mostrará las próximas comidas cercanas:

Screenshot of the Nerd Dinner home page with a map shown.

Al mantener el puntero sobre una comida se mostrarán detalles sobre ella.

Al hacer clic en el título de la comida en la burbuja o en el lado derecho de la lista HTML, iremos a la comida, la cual podemos RSVP para:

Screenshot of the Nerd Dinner details page with a map showing navigation to a dinner.

siguiente paso

Ahora hemos implementado toda la funcionalidad de la aplicación de nuestra aplicación NerdDinner. Ahora veamos cómo se habilitan las pruebas unitarias automatizadas.