David East
David East

Reputation: 32614

MVC3 jQuery Autocomplete - User must select option, ID of option sent to server

Background
Currently in a project of mine I am using jQuery autocomplete for a few fields.

To provide context, the application records Runs. Each Run must have a Route associated with it. A Route meaning, where the user ran.

When the user types in a Route, a list of their Routes gets displayed by the autocomplete options, but the database requires the RouteID for validation.

To compensate for this, I stored the RouteID in a HiddenFor HtmlHelper. When the user selects the route from the autocomplete, the HiddenFor gets assigned.

What my problem is
If the user types in the full name of the Route, instead of selecting it from the autocomplete list or enters a Route that does not exist, the HiddenFor will not get assigned. When this happens, I have to find the Route by its name and validate that it exists on the server.

I would like to not have to create this work-around for every autocomplete.

The bottom line
Is there anyway to make the autocomplete act more like a select list? I want the user to have no choice but to select the text of one option from the autocomplete list, and have the value of the selected option is sent to the server.

If I have to stick to the HiddenFor method, is there at least a way to force the user to select an option from the autocomplete list?


Below is the code I am currently using

Mark-up

@Html.LabelFor(model => model.RouteID, "Route")
<input type="text" data-autocomplete-url="@Url.Action("../Route/GetRoutesByUser")" />
@Html.HiddenFor(m => m.RouteID)

jQuery

  $('*[data-autocomplete-url]')
    .each(function () {
        $(this).autocomplete({
            source: $(this).data("autocomplete-url"),
            minLength: 2,
            select: function (event, ui) {
                log(ui.item.id, ui.item.name);
            }
        });
    });

Code

public ActionResult GetRoutesByUser(string term)
{
    var routeList = db.Routes.Where(r => r.Name.Contains(term))
                    .Take(5)
                    .Select(r => new { id = r.RouteID, label = r.Name, name = "RouteID"});
    return Json(routeList, JsonRequestBehavior.AllowGet);
}

Upvotes: 2

Views: 4270

Answers (2)

David East
David East

Reputation: 32614

Alright, after a lot of fiddling around I came up with the following implementation:

The code below is a HtmlHelper called @Html.AutocompleteWithHiddenFor. The HtmlHelper will create an input HTML element with a data-autocomplete-url property based on the controller and action passed in.

If the input element needs a value then you can pass that in as well. A HiddenFor will be created for the Model property passed in and a ValidationMessageFor will be created for the Model as well.

Now all I have to do is use @Html.AutocompleteWithHiddenFor, and pass in whatever expression I need with the controller and action (and possibly the value) to get the autocomplete functionality and have the ID pass to the server instead of the text.

jQuery

$(function () {
    function log(id, name) {
        var hidden = $('#' + name);
        hidden.attr("value", id);
    }

    $('*[data-autocomplete-url]')
    .each(function () {
        $(this).autocomplete({
            source: $(this).data("autocomplete-url"),
            minLength: 2,
            select: function (event, ui) {
                log(ui.item.id, ui.item.name);
            },
            change: function (event, ui) {
                if (!ui.item) {
                    this.value = '';
                } else {
                    log(ui.item.id, ui.item.name);
                }
            }
        });
    });
});

AutocompleteHelper class

public static class AutocompleteHelper
{
     /// <summary>
     /// Given a Model's property, a controller, and a method that belongs to that controller, 
     /// this function will create an input html element with a data-autocomplete-url property
     /// with the method the autocomplete will need to call the method. A HiddenFor will be
     /// created for the Model's property passed in, so the HiddenFor will be validated 
     /// and the html input will not.
     /// </summary>
      /// <returns></returns>
      public static MvcHtmlString AutocompleteWithHiddenFor<TModel, TProperty>(this HtmlHelper<TModel> html, Expression<Func<TModel, TProperty>> expression, string controllerName, string actionName, object value = null)
      {
          // Create the URL of the Autocomplete function
          string autocompleteUrl = UrlHelper.GenerateUrl(null, actionName,
                                                       controllerName,
                                                       null,
                                                       html.RouteCollection,
                                                       html.ViewContext.RequestContext,                                                              
                                                       includeImplicitMvcValues: true);

           // Create the input[type='text'] html element, that does 
           // not need to be aware of the model
           String textbox = "<input type='text' data-autocomplete-url='" + autocompleteUrl + "'";

           // However, it might need to be have a value already populated
           if (value != null)
           {
               textbox += "value='" + value.ToString() + "'";
           }

           // close out the tag
           textbox += " />";

           // A validation message that will fire depending on any 
           // attributes placed on the property
           MvcHtmlString valid = html.ValidationMessageFor(expression);

           // The HiddenFor that will bind to the ID needed rather than 
           // the text received from the Autocomplete
           MvcHtmlString hidden = html.HiddenFor(expression);

           string both = textbox + " " + hidden + " " + valid;
           return MvcHtmlString.Create(both);
     }
}

View

@Html.LabelFor(model => model.RouteID, "Route")
@Html.AutocompleteWithHiddenFor(model => model.RouteID, "Route", "GetRoutesByUser") 

Or if it needs a value

@Html.LabelFor(model => model.Route, "Route")
@Html.AutocompleteWithHiddenFor(model => model.RouteID, "Route", "GetRoutesByUser", @Model.RouteName) 

Upvotes: 1

Andrew Whitaker
Andrew Whitaker

Reputation: 126072

I would use the change event for this, clearing the value of the input if an item was not selected:

$('*[data-autocomplete-url]')
    .each(function () {
        $(this).autocomplete({
            source: $(this).data("autocomplete-url"),
            minLength: 2,
            select: function (event, ui) {
                log(ui.item.id, ui.item.name);
            },
            change: function (event, ui) { 
                if (!ui.item) { 
                    this.value = '';
                } else {
                    // Populate your hidden input.
                }
            }
        });
    });
});

Example: http://jsfiddle.net/urEzm/

Upvotes: 1

Related Questions