Knockout.js: Cannot bind to custom @Html.EditorFor
asp.net-mvc, asp.net-mvc-3, knockout-2.0, knockout.js
Solution
UPDATE3
First good call on the underscore bit, I totally forgot about that. Your bit of code looks almost right, but should actually be this:
@Html.FullFieldEditor(m => m.MyModel.MyRadioButton, new Dictionary<string, object> { { "data_bind", "checked:myRadioButton" } })
So since you are dynamically selecting between checkbox and text, you'll have to do a couple of things. If its a checkbox, you'll have to use the code above. If its a textbox, you'll have to use:
@Html.FullFieldEditor(m => m.MyModel.MyTextBox, new Dictionary<string, object> { { "data_bind", "value:MyTextBox" } })
UPDATE2
So I updated your code where I think the data-bind belongs in your html (marked option1 and option2). What would be helpful is if you gave me a snippet of the html being generated, along with where you need the data bind.
public static MvcHtmlString FullFieldEditor<T, TValue>(this HtmlHelper<T> html, Expression<Func<T, TValue>> expression)
{
return FullFieldEditor(html, expression, null);
}
public static MvcHtmlString FullFieldEditor<T, TValue>(this HtmlHelper<T> html, Expression<Func<T, TValue>> expression, object htmlAttributes)
{
var metadata = ModelMetadata.FromLambdaExpression(expression, html.ViewData);
if (!metadata.ShowForEdit)
{
return MvcHtmlString.Empty;
}
if (metadata.HideSurroundingHtml)
{
return html.EditorFor(expression);
}
var wrapper = new TagBuilder("div");
wrapper.AddCssClass("field-wrapper");
var table = new TagBuilder("table");
table.Attributes["border"] = "0";
//added this to even out table columns
table.Attributes["width"] = "100%";
var tbody = new TagBuilder("tbody");
var td1 = new TagBuilder("td");
td1.Attributes["width"] = "40%";
td1.Attributes["valign"] = "top";
var label = new TagBuilder("div");
label.AddCssClass("field-label");
label.AddCssClass("mylabelstyle");
label.InnerHtml += html.MyLabelFor(expression);
td1.InnerHtml = label.ToString();
var td2 = new TagBuilder("td");
td2.Attributes["width"] = "50%";
td2.Attributes["valign"] = "top";
var input = new TagBuilder("div");
input.AddCssClass("field-input");
// option1
input.InnerHtml += html.EditorFor(expression, htmlAttributes);
td2.InnerHtml = input.ToString();
var td3 = new TagBuilder("td");
td3.Attributes["width"] = "5%";
td3.Attributes["valign"] = "top";
if (metadata.IsRequired && !metadata.IsReadOnly)
{
// option2
td3.InnerHtml += html.RequiredFor(expression, htmlAttributes);
}
var td4 = new TagBuilder("td");
td4.Attributes["width"] = "5%";
td4.Attributes["valign"] = "middle";
if (!string.IsNullOrEmpty(metadata.Description))
{
td4.InnerHtml += html.TooltipFor(expression);
}
else
{
td4.InnerHtml += html.SpacerFor(expression);
}
td4.InnerHtml += html.ValidationMessageFor(expression);
var tr = new TagBuilder("tr");
tr.InnerHtml = td1.ToString() + td2.ToString() + td3.ToString() + td4.ToString();
tbody.InnerHtml = tr.ToString();
table.InnerHtml = tbody.ToString();
wrapper.InnerHtml = table.ToString();
return new MvcHtmlString(wrapper + Environment.NewLine);
}
UPDATE
While I still believe below is the "correct" answer, here is what your tagbuilder is missing so you can pass custom attributes along:
input.MergeAttributes(new RouteValueDictionary(htmlAttributes));
Original Answer
I have a feeling that what is going to happen by trying to mix razor and knockout, is the razor stuff will render, then when knockout is attached, the values in the knockout viewmodel are going to override whatever was in the razor view.
Here is my suggestion if you are trying to refactor to knockout:
- Create an html only view (no razor).
- Add your knockout bindings to it.
- Pass your model data to knockout in the form of a json object using Json.NET. You can do this in a variety of ways, but the easiest being simply stuffing the data on the view inside of a `<script>` tag that your javascript can find.
- Use Knockout Mapping to load the json object into the viewmodel.
Problem
I am using a selector to build a custom @Html.EditorFor (called @Html.FullFieldEditor). It determines the type of input to generate (textbox, drop down, radio buttons, check boxes, etc.). I have been trying to hook it into one for a radio button list, thusly: ``` @Html.FullFieldEditor(m => m.MyModel.MyRadioButton, new Dictionary<string, object> { { "data_bind", "myRadioButton" } }) ``` or like this: ``` @Html.FullFieldEditor(m => m.MyModel.MyRadioButton, new { data_bind = "checked: myRadioButton" }) ``` But with no luck. I was trying to fix my `selector.cshtml` code but only wound up making a horrible mess. Here is the code that has worked for me BEFORE I was trying to implement `knockout.js`: ``` @{ var supportsMany = typeof (IEnumerable).IsAssignableFrom(ViewData.ModelMetadata.ModelType); var selectorModel = (Selector)ViewData.ModelMetadata.AdditionalValues["SelectorModelMetadata"]; var fieldName = ViewData.TemplateInfo.GetFullHtmlFieldName(""); var validationClass = ViewData.ModelState.IsValidField(fieldName) ? "" : "input-validation-error"; // Loop through the items and make sure they are Selected if the value has been posted if(Model != null) { foreach (var item in selectorModel.Items) { if (supportsMany) { var modelStateValue = GetModelStateValue<string[]>(Html, fieldName) ?? ((IEnumerable)Model).OfType<object>().Select(m => m.ToString()); item.Selected = modelStateValue.Contains(item.Value); } else { var modelStateValue = GetModelStateValue<string>(Html, fieldName); if (modelStateValue != null) { item.Selected = modelStateValue.Equals(item.Value, StringComparison.OrdinalIgnoreCase); } else { Type modelType = Model.GetType(); if (modelType.IsEnum) { item.Selected = item.Value == Model.ToString(); } } } } } } @functions { public MvcHtmlString BuildInput(string fieldName, SelectListItem item, string inputType, object htmlAttributes) // UPDATE: Trying to do it above { var id = ViewData.TemplateInfo.GetFullHtmlFieldId(item.Value); var wrapper = new TagBuilder("div"); wrapper.AddCssClass("selector-item"); var input = new TagBuilder("input"); input.MergeAttribute("type", inputType); input.MergeAttribute("name", fieldName); input.MergeAttribute("value", item.Value); input.MergeAttribute("id", id); input.MergeAttributes(new RouteValueDictionary(htmlAttributes)); // UPDATE: and trying above, but see below in the // @foreach...@BuildInput section input.MergeAttributes(Html.GetUnobtrusiveValidationAttributes(fieldName, ViewData.ModelMetadata)); if(item.Selected) input.MergeAttribute("checked", "checked"); wrapper.InnerHtml += input.ToString(TagRenderMode.SelfClosing); var label = new TagBuilder("label"); label.MergeAttribute("for", id); label.InnerHtml = item.Text; wrapper.InnerHtml += label; return new MvcHtmlString(wrapper.ToString()); } /// <summary> /// Get the raw value from model state /// </summary> public static T GetModelStateValue<T>(HtmlHelper helper, string key) { ModelState modelState; if (helper.ViewData.ModelState.TryGetValue(key, out modelState) && modelState.Value != null) return (T)modelState.Value.ConvertTo(typeof(T), null); return default(T); } } @if (ViewData.ModelMetadata.IsReadOnly) { var readonlyText = selectorModel.Items.Where(i => i.Selected).ToDelimitedString(i => i.Text); if (string.IsNullOrWhiteSpace(readonlyText)) { readonlyText = selectorModel.OptionLabel ?? "Not Set"; } @readonlyText foreach (var item in selectorModel.Items.Where(i => i.Selected)) { @Html.Hidden(fieldName, item.Value) } } else { if (selectorModel.AllowMultipleSelection) { if (selectorModel.Items.Count() < selectorModel.BulkSelectionThreshold) { <div class="@validationClass"> @foreach (var item in selectorModel.Items) { @BuildInput(fieldName, item, "checkbox") // throwing error here if I leave this as is (needs 4 arguments) //But if I do this: //@BuildInput(fieldName, item, "checkbox", htmlAttributes) // I get does not exit in current context } </div> } else { @Html.ListBox("", selectorModel.Items) } } else if (selectorModel.Items.Count() < selectorModel.BulkSelectionThreshold) { <div class="@validationClass"> @*@if (selectorModel.OptionLabel != null) { @BuildInput(fieldName, new SelectListItem { Text = selectorModel.OptionLabel, Value = "" }, "radio") }*@ @foreach (var item in selectorModel.Items) { @BuildInput(fieldName, item, "radio")//same here } </div> } else { @Html.DropDownList("", selectorModel.Items, selectorModel.OptionLabel) } } ``` Any help is greatly appreciated. EDIT I am trying to minimize existing `JS` code (over 1500 lines) to show/hide with KO (which just seems to be able to cut down the code considerably). I know I've got choices (`visible`, `if`, etc.) with KO, but assuming what I wanted to accomplish with KO was doable I could go with `visible`. In any event, getting past the binding hurdle prevents me from getting that far. Here is an example of some code I am using to show/hide with plain `JS`: ``` $(document).ready(function () { $("input[name$='MyModel.MyRadioButton']").click(function () { var radio_value = $(this).val(); if (radio_value == '1') { $("#MyRadioButton_1").show(); $("#MyRadioButton_2").hide(); $("#MyRadioButton_3").hide(); } else if (radio_value == '2') { $("#MyRadioButton_1").show(); $("#MyRadioButton_2").hide(); $("#MyRadioButton_3").hide(); } else if (radio_value == '3') { $("#MyRadioButton_1").show(); $("#MyRadioButton_2").hide(); $("#MyRadioButton_3").hide(); } }); $("#MyRadioButton_1").hide(); $("#MyRadioButton_2").hide(); $("#MyRadioButton_3").hide(); }); ``` I figured KO could minimize the above. Again, I'm looking at 20-30 inputs, most with more than 3 choices (a few have 10 choices in a Drop Down). This is getting hard to maintain at 1500 lines and growing. And then in my `view` I've got this going on: ``` <div id="MyRadioButton_1"> @Helpers.StartingCost(MyModel.Choice1, "1") </div> <div id="MyRadioButton_2"> @Helpers.StartingCost(MyModel.Choice2, "2") </div> <div id="MyRadioButton_3"> @Helpers.StartingCost(MyModel.Choice2, "2") </div> ``` The `view` code above will change slightly with KO, but again its the `JS` I am trying to cut down on. EDIT 2 This is part of the code for `FullFieldEditor`. Some parts are left out for brevity (such as code for `RequiredFor`, `ToolTipFor` and `SpacerFor`). ``` public static MvcHtmlString FullFieldEditor<T, TValue>(this HtmlHelper<T> html, Expression<Func<T, TValue>> expression) { return FullFieldEditor(html, expression, null); } public static MvcHtmlString FullFieldEditor<T, TValue>(this HtmlHelper<T> html, Expression<Func<T, TValue>> expression, object htmlAttributes) { var metadata = ModelMetadata.FromLambdaExpression(expression, html.ViewData); if (!metadata.ShowForEdit) { return MvcHtmlString.Empty; } if (metadata.HideSurroundingHtml) { return html.EditorFor(expression); } var wrapper = new TagBuilder("div"); wrapper.AddCssClass("field-wrapper"); var table = new TagBuilder("table"); table.Attributes["border"] = "0"; table.Attributes["width"] = "100%";//added this to even out table columns var tbody = new TagBuilder("tbody"); var tr = new TagBuilder("tr"); var td1 = new TagBuilder("td"); td1.Attributes["width"] = "40%"; td1.Attributes["valign"] = "top"; var label = new TagBuilder("div"); label.AddCssClass("field-label"); label.AddCssClass("mylabelstyle"); label.InnerHtml += html.MyLabelFor(expression); td1.InnerHtml = label.ToString(); var td2 = new TagBuilder("td"); td2.Attributes["width"] = "50%"; td2.Attributes["valign"] = "top"; var input = new TagBuilder("div"); input.AddCssClass("field-input"); input.InnerHtml += html.EditorFor(expression); td2.InnerHtml = input.ToString(); var td3 = new TagBuilder("td"); td3.Attributes["width"] = "5%"; td3.Attributes["valign"] = "top"; if (metadata.IsRequired && !metadata.IsReadOnly) { td3.InnerHtml += html.RequiredFor(expression); } var td4 = new TagBuilder("td"); td4.Attributes["width"] = "5%"; td4.Attributes["valign"] = "middle"; if (!string.IsNullOrEmpty(metadata.Description)) { td4.InnerHtml += html.TooltipFor(expression); } else td4.InnerHtml += html.SpacerFor(expression); td4.InnerHtml += html.ValidationMessageFor(expression); tr.InnerHtml = td1.ToString() + td2.ToString() + td3.ToString() + td4.ToString(); tbody.InnerHtml = tr.ToString(); table.InnerHtml = tbody.ToString(); wrapper.InnerHtml = table.ToString(); return new MvcHtmlString(wrapper + Environment.NewLine); } ``` UPDATE 3 The options are not working. Option 1 will not even show `data-bind` in the `<input>`. Option 2 will not work since it's just checking if the field is required (the code just shows a "required" image if it is). When I tried your first suggestion before your "UPDATE2" (`input.MergeAttributes(new RouteValueDictionary(htmlAttributes));`), this was the output: ``` <div class="field-input" data_bind="checked: MyRadioButton"> <div class=""> <div class="selector-item"> <input id="MyModel_MyRadioButton_Choice1" name="MyModel.MyRadioButton" type="radio" value="Choice1"> <label for="MyModel_MyRadioButton_Choice1">I am thinking about Choice 1.</label> </div> <!--Just showing one radio button for brevity--> </div> </div> ``` Since I merged the attribute with the `input` part of `TagBuilder`, which is outputting the `field-input` `<div>`, that is where it's being placed (which is logical). Notice that it should be `data-bind` but is showing as `data_bind` in the `field-input` class. This is how I have the `FullFieldEditor`: ``` @Html.FullFieldEditor(m => m.MyModel.MyRadioButton, new Dictionary<string, object> { { "data_bind", "myRadioButton" } }) ``` What it should be showing up as is this, I think: ``` <div class="field-input"> <div class=""> <div class="selector-item"> <!-- "data-bind" should be showing up in the following INPUT, correct?--> <input data-bind="checked: MyRadioButton" id="MyModel_MyRadioButton_Choice1" name="MyModel.MyRadioButton" type="radio" value="Choice1"> <label for="MyModel_MyRadioButton_Choice1">I am thinking about Choice 1.</label> </div> <!--Just showing one radio button for brevity--> </div> </div> ``` What I suspect is that I have to get that `htmlAttributes` into the `Selector.cshtml` above, and not in the `HtmlFormHelper.cs` file. The `Selector.cshtml` is what is making the determination between showing, for example, a drop down list, a radio button list or a checkbox list (among others). The `Selector.cshtml` is a template in the `Shared\EditorTemplates` folder. For background: I have dozens of forms representing hundreds of inputs over dozens of pages (or wizards). I am using the `@Html.FullFieldEditor` because it was easier to maintain than having spaghetti code for each type of input (drop down, checkbox, radio buttons, etc.). UPDATE 4 Still not working. I tried this in the `Selector.cshtml` (its the `BuildInput` function)code and was able to get "data-bind" into the `<input>` tag for each radio button in the list: ``` input.MergeAttribute("data-bind", htmlAttributes); ``` and then I did this lower down in the same file: ``` @foreach (var item in selectorModel.Items) { @BuildInput(fieldName, item, "radio", "test") } ``` and my HTML output is this: ``` <div class="selector-item"> <input data-bind="test" id="MyModel_MyRadioButton_Choice1" name="MyModel.MyRadioButton" type="radio" value="Choice1"> <label for="MyModel_MyRadioButton_Choice1">Choice 1</label> </div> ``` Which is what is leading me to believe it's the `Selector.cshtml` file, not the `HtmlFormHelper.cs` file. I am going to open up the bounty to everyone 50+.