Add `<option/>` tag helper

- #1423
This commit is contained in:
Doug Bunting 2014-11-07 14:08:00 -08:00
parent 30f25fec99
commit 2d32420f01
2 changed files with 354 additions and 0 deletions

View File

@ -0,0 +1,99 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using Microsoft.AspNet.Mvc.Rendering;
using Microsoft.AspNet.Razor.Runtime.TagHelpers;
using Microsoft.AspNet.Razor.TagHelpers;
namespace Microsoft.AspNet.Mvc.TagHelpers
{
/// <summary>
/// <see cref="ITagHelper"/> implementation targeting &lt;option&gt; elements.
/// </summary>
/// <remarks>
/// This <see cref="ITagHelper"/> works in conjunction with <see cref="SelectTagHelper"/>. It has
/// <see cref="ContentBehavior.Modify"/> in order to read element's content but does not modify that content. The
/// only modification it makes is to add a <c>selected</c> attribute in some cases.
/// </remarks>
[ContentBehavior(ContentBehavior.Modify)]
public class OptionTagHelper : TagHelper
{
// Protected to ensure subclasses are correctly activated. Internal for ease of use when testing.
[Activate]
protected internal IHtmlGenerator Generator { get; set; }
// Protected to ensure subclasses are correctly activated. Internal for ease of use when testing.
[Activate]
protected internal ViewContext ViewContext { get; set; }
/// <summary>
/// Specifies that this &lt;option&gt; is pre-selected.
/// </summary>
/// <remarks>
/// Passed through to the generated HTML in all cases.
/// </remarks>
public string Selected { get; set; }
/// <summary>
/// Specifies a value for the &lt;option&gt; element.
/// </summary>
/// <remarks>
/// Passed through to the generated HTML in all cases.
/// </remarks>
public string Value { get; set; }
/// <inheritdoc />
/// <remarks>
/// Does nothing unless <see cref="FormContext.FormData"/> contains a
/// <see cref="SelectTagHelper.SelectedValuesFormDataKey"/> entry and that entry is a non-empty
/// <see cref="ICollection{string}"/> instance. Also does nothing if the associated &lt;option&gt; is already
/// selected.
/// </remarks>
public override void Process(TagHelperContext context, TagHelperOutput output)
{
// Pass through attributes that are also well-known HTML attributes.
if (Value != null)
{
output.CopyHtmlAttribute(nameof(Value), context);
}
if (Selected != null)
{
// This <option/> will always be selected.
output.CopyHtmlAttribute(nameof(Selected), context);
}
else
{
// Is this <option/> element a child of a <select/> element the SelectTagHelper targeted?
object formDataEntry;
ViewContext.FormContext.FormData.TryGetValue(
SelectTagHelper.SelectedValuesFormDataKey,
out formDataEntry);
// ... And did the SelectTagHelper determine any selected values?
var selectedValues = formDataEntry as ICollection<string>;
if (selectedValues != null && selectedValues.Count != 0)
{
// Encode all selected values for comparison with element content.
var encodedValues = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var selectedValue in selectedValues)
{
encodedValues.Add(Generator.Encode(selectedValue));
}
// Select this <option/> element if value attribute or content matches a selected value. Callers
// encode values as-needed before setting TagHelperOutput.Content. But TagHelperOutput itself
// encodes attribute values later, when GenerateStartTag() is called.
var text = output.Content;
var selected = (Value != null) ? selectedValues.Contains(Value) : encodedValues.Contains(text);
if (selected)
{
output.Attributes.Add("selected", "selected");
}
}
}
}
}
}

View File

@ -0,0 +1,255 @@
// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNet.Mvc.ModelBinding;
using Microsoft.AspNet.Razor.Runtime.TagHelpers;
using Xunit;
namespace Microsoft.AspNet.Mvc.TagHelpers
{
public class OptionTagHelperTest
{
// Original content, selected attribute, value attribute, selected values (to place in FormContext.FormData)
// and expected output (concatenation of TagHelperOutput generations).
public static TheoryData<string, string, string, ICollection<string>, string> GeneratesExpectedDataSet
{
get
{
return new TheoryData<string, string, string, ICollection<string>, string>
{
{ null, null, null, null,
"<not-option label=\"my-label\"></not-option>" },
{ null, string.Empty, "value", null,
"<not-option label=\"my-label\" value=\"value\" selected=\"\"></not-option>" },
{ null, "selected", "value", null,
"<not-option label=\"my-label\" value=\"value\" selected=\"selected\"></not-option>" },
{ null, null, "value", new string[0],
"<not-option label=\"my-label\" value=\"value\"></not-option>" },
{ null, null, "value", new [] { string.Empty, },
"<not-option label=\"my-label\" value=\"value\"></not-option>" },
{ null, string.Empty, "value", new [] { string.Empty, },
"<not-option label=\"my-label\" value=\"value\" selected=\"\"></not-option>" },
{ null, null, "value", new [] { "value", },
"<not-option label=\"my-label\" value=\"value\" selected=\"selected\"></not-option>" },
{ null, null, "value", new [] { string.Empty, "value", },
"<not-option label=\"my-label\" value=\"value\" selected=\"selected\"></not-option>" },
{ string.Empty, null, null, null,
"<not-option label=\"my-label\"></not-option>" },
{ string.Empty, string.Empty, null, null,
"<not-option label=\"my-label\" selected=\"\"></not-option>" },
{ string.Empty, "selected", null, null,
"<not-option label=\"my-label\" selected=\"selected\"></not-option>" },
{ string.Empty, null, null, new string[0],
"<not-option label=\"my-label\"></not-option>" },
{ string.Empty, null, null, new [] { string.Empty, },
"<not-option label=\"my-label\" selected=\"selected\"></not-option>" },
{ string.Empty, string.Empty, null, new [] { string.Empty, },
"<not-option label=\"my-label\" selected=\"\"></not-option>" },
{ string.Empty, null, null, new [] { "text", },
"<not-option label=\"my-label\"></not-option>" },
{ string.Empty, null, null, new [] { string.Empty, "text", },
"<not-option label=\"my-label\" selected=\"selected\"></not-option>" },
{ "text", null, null, null,
"<not-option label=\"my-label\">text</not-option>" },
{ "text", string.Empty, null, null,
"<not-option label=\"my-label\" selected=\"\">text</not-option>" },
{ "text", "selected", null, null,
"<not-option label=\"my-label\" selected=\"selected\">text</not-option>" },
{ "text", null, null, new string[0],
"<not-option label=\"my-label\">text</not-option>" },
{ "text", null, null, new [] { string.Empty, },
"<not-option label=\"my-label\">text</not-option>" },
{ "text", null, null, new [] { "text", },
"<not-option label=\"my-label\" selected=\"selected\">text</not-option>" },
{ "text", string.Empty, null, new [] { "text", },
"<not-option label=\"my-label\" selected=\"\">text</not-option>" },
{ "text", null, null, new [] { string.Empty, "text", },
"<not-option label=\"my-label\" selected=\"selected\">text</not-option>" },
{ "text", string.Empty, "value", null,
"<not-option label=\"my-label\" value=\"value\" selected=\"\">text</not-option>" },
{ "text", "selected", "value", null,
"<not-option label=\"my-label\" value=\"value\" selected=\"selected\">text</not-option>" },
{ "text", null, "value", new string[0],
"<not-option label=\"my-label\" value=\"value\">text</not-option>" },
{ "text", null, "value", new [] { string.Empty, },
"<not-option label=\"my-label\" value=\"value\">text</not-option>" },
{ "text", string.Empty, "value", new [] { string.Empty, },
"<not-option label=\"my-label\" value=\"value\" selected=\"\">text</not-option>" },
{ "text", null, "value", new [] { "text", },
"<not-option label=\"my-label\" value=\"value\">text</not-option>" },
{ "text", null, "value", new [] { "value", },
"<not-option label=\"my-label\" value=\"value\" selected=\"selected\">text</not-option>" },
{ "text", null, "value", new [] { string.Empty, "value", },
"<not-option label=\"my-label\" value=\"value\" selected=\"selected\">text</not-option>" },
};
}
}
// Original content, selected attribute, value attribute, selected values (to place in FormContext.FormData)
// and expected output (concatenation of TagHelperOutput generations). Excludes non-null selected attribute,
// null selected values, and empty selected values cases.
public static IEnumerable<object []> DoesNotUseGeneratorDataSet
{
get
{
return GeneratesExpectedDataSet.Where(
entry => (entry[1] != null || entry[3] == null || (entry[3] as ICollection<string>).Count == 0));
}
}
// Original content, selected attribute, value attribute, selected values (to place in FormContext.FormData)
// and expected output (concatenation of TagHelperOutput generations). Excludes non-null selected attribute
// cases.
public static IEnumerable<object[]> DoesNotUseViewContextDataSet
{
get
{
return GeneratesExpectedDataSet.Where(entry => entry[1] != null);
}
}
[Theory]
[MemberData(nameof(GeneratesExpectedDataSet))]
public async Task ProcessAsync_GeneratesExpectedOutput(
string originalContent,
string selected,
string value,
ICollection<string> selectedValues,
string expectedOutput)
{
// Arrange
var originalAttributes = new Dictionary<string, string>
{
{ "label", "my-label" },
};
var originalTagName = "not-option";
var contextAttributes = new Dictionary<string, object>
{
{ "label", "my-label" },
{ "selected", selected },
{ "value", value },
};
var tagHelperContext = new TagHelperContext(contextAttributes);
var output = new TagHelperOutput(originalTagName, originalAttributes, originalContent)
{
SelfClosing = false,
};
var metadataProvider = new EmptyModelMetadataProvider();
var htmlGenerator = new TestableHtmlGenerator(metadataProvider);
var viewContext = TestableHtmlGenerator.GetViewContext(
model: null,
htmlGenerator: htmlGenerator,
metadataProvider: metadataProvider);
viewContext.FormContext.FormData[SelectTagHelper.SelectedValuesFormDataKey] = selectedValues;
var tagHelper = new OptionTagHelper
{
Generator = htmlGenerator,
Selected = selected,
Value = value,
ViewContext = viewContext,
};
// Act
await tagHelper.ProcessAsync(tagHelperContext, output);
// Assert
Assert.Equal(
expectedOutput,
output.GenerateStartTag() + output.GenerateContent() + output.GenerateEndTag());
}
[Theory]
[MemberData(nameof(DoesNotUseGeneratorDataSet))]
public async Task ProcessAsync_DoesNotUseGenerator_IfSelectedNullOrNoSelectedValues(
string originalContent,
string selected,
string value,
ICollection<string> selectedValues,
string ignored)
{
// Arrange
var originalAttributes = new Dictionary<string, string>
{
{ "label", "my-label" },
};
var originalTagName = "not-option";
var contextAttributes = new Dictionary<string, object>
{
{ "label", "my-label" },
{ "selected", selected },
{ "value", value },
};
var tagHelperContext = new TagHelperContext(contextAttributes);
var output = new TagHelperOutput(originalTagName, originalAttributes, originalContent)
{
SelfClosing = false,
};
var metadataProvider = new EmptyModelMetadataProvider();
var htmlGenerator = new TestableHtmlGenerator(metadataProvider);
var viewContext = TestableHtmlGenerator.GetViewContext(
model: null,
htmlGenerator: htmlGenerator,
metadataProvider: metadataProvider);
viewContext.FormContext.FormData[SelectTagHelper.SelectedValuesFormDataKey] = selectedValues;
var tagHelper = new OptionTagHelper
{
Selected = selected,
Value = value,
ViewContext = viewContext,
};
// Act & Assert
// Tag helper would throw an NRE if it used Generator value.
await Assert.DoesNotThrowAsync(() => tagHelper.ProcessAsync(tagHelperContext, output));
}
[Theory]
[MemberData(nameof(DoesNotUseViewContextDataSet))]
public async Task ProcessAsync_DoesNotUseViewContext_IfSelectedNotNull(
string originalContent,
string selected,
string value,
ICollection<string> ignoredValues,
string ignoredOutput)
{
// Arrange
var originalAttributes = new Dictionary<string, string>
{
{ "label", "my-label" },
};
var originalTagName = "not-option";
var contextAttributes = new Dictionary<string, object>
{
{ "label", "my-label" },
{ "selected", selected },
{ "value", value },
};
var tagHelperContext = new TagHelperContext(contextAttributes);
var output = new TagHelperOutput(originalTagName, originalAttributes, originalContent)
{
SelfClosing = false,
};
var tagHelper = new OptionTagHelper
{
Selected = selected,
Value = value,
};
// Act & Assert
// Tag helper would throw an NRE if it used ViewContext or Generator values.
await Assert.DoesNotThrowAsync(() => tagHelper.ProcessAsync(tagHelperContext, output));
}
}
}