Skip to content
This repository was archived by the owner on Dec 14, 2018. It is now read-only.

Commit f1eefdb

Browse files
author
N. Taylor Mullen
committed
Enable CopyHtmlAttribute to maintain copied attribute order.
- Updated implementation to do a best guess at copying an attribute back into `TagHelperOutput.Attributes`. - Updated existing functional and unit tests to account for maintained attribute order. - Added a set of complex order based unit tests to validate `CopyHtmlAttribute` properly maintains order. #2639
1 parent 405d105 commit f1eefdb

19 files changed

+497
-92
lines changed

src/Microsoft.AspNet.Mvc.TagHelpers/TagHelperOutputExtensions.cs

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,30 +23,47 @@ public static class TagHelperOutputExtensions
2323
/// <param name="tagHelperOutput">The <see cref="TagHelperOutput"/> this method extends.</param>
2424
/// <param name="attributeName">The name of the bound attribute.</param>
2525
/// <param name="context">The <see cref="TagHelperContext"/>.</param>
26-
/// <remarks>Only copies the attribute if <paramref name="tagHelperOutput"/>'s
26+
/// <remarks>
27+
/// <para>
28+
/// Only copies the attribute if <paramref name="tagHelperOutput"/>'s
2729
/// <see cref="TagHelperOutput.Attributes"/> does not contain an attribute with the given
28-
/// <paramref name="attributeName"/>.</remarks>
30+
/// <paramref name="attributeName"/>.
31+
/// </para>
32+
/// <para>
33+
/// Duplicate attributes same name in <paramref name="context"/>'s <see cref="TagHelperContext.AllAttributes"/>
34+
/// or <paramref name="tagHelperOutput"/>'s <see cref="TagHelperOutput.Attributes"/> may result in copied
35+
/// attribute order not being maintained.
36+
/// </para></remarks>
2937
public static void CopyHtmlAttribute(
3038
[NotNull] this TagHelperOutput tagHelperOutput,
3139
[NotNull] string attributeName,
3240
[NotNull] TagHelperContext context)
3341
{
3442
if (!tagHelperOutput.Attributes.ContainsName(attributeName))
3543
{
36-
IEnumerable<IReadOnlyTagHelperAttribute> entries;
44+
var copiedAttribute = false;
3745

38-
// We look for the original attribute so we can restore the exact attribute name the user typed.
39-
// Approach also ignores changes made to tagHelperOutput[attributeName].
40-
if (!context.AllAttributes.TryGetAttributes(attributeName, out entries))
46+
// We iterate context.AllAttributes backwards since we prioritize TagHelperOutput values occurring
47+
// before the current context.AllAttribtes[i].
48+
for (var i = context.AllAttributes.Count - 1; i >= 0; i--)
4149
{
42-
throw new ArgumentException(
43-
Resources.FormatTagHelperOutput_AttributeDoesNotExist(attributeName, nameof(TagHelperContext)),
44-
nameof(attributeName));
50+
// We look for the original attribute so we can restore the exact attribute name the user typed in
51+
// approximately the same position where the user wrote it in the Razor source.
52+
if (string.Equals(
53+
attributeName,
54+
context.AllAttributes[i].Name,
55+
StringComparison.OrdinalIgnoreCase))
56+
{
57+
CopyHtmlAttribute(i, tagHelperOutput, context);
58+
copiedAttribute = true;
59+
}
4560
}
4661

47-
foreach (var entry in entries)
62+
if (!copiedAttribute)
4863
{
49-
tagHelperOutput.Attributes.Add(entry.Name, entry.Value);
64+
throw new ArgumentException(
65+
Resources.FormatTagHelperOutput_AttributeDoesNotExist(attributeName, nameof(TagHelperContext)),
66+
nameof(attributeName));
5067
}
5168
}
5269
}
@@ -100,5 +117,61 @@ public static void RemoveRange(
100117
tagHelperOutput.Attributes.Remove(attribute);
101118
}
102119
}
120+
121+
private static void CopyHtmlAttribute(
122+
int allAttributeIndex,
123+
TagHelperOutput tagHelperOutput,
124+
TagHelperContext context)
125+
{
126+
var existingAttribute = context.AllAttributes[allAttributeIndex];
127+
var copiedAttribute = new TagHelperAttribute
128+
{
129+
Name = existingAttribute.Name,
130+
Value = existingAttribute.Value,
131+
Minimized = existingAttribute.Minimized
132+
};
133+
134+
// Move backwards through context.AllAttributes from the provided index until we find a familiar attribute
135+
// in tagHelperOutput where we can insert the copied value after the familiar one.
136+
for (var i = allAttributeIndex - 1; i >= 0; i--)
137+
{
138+
var previousName = context.AllAttributes[i].Name;
139+
var index = IndexOfFirstMatch(previousName, tagHelperOutput.Attributes);
140+
if (index != -1)
141+
{
142+
tagHelperOutput.Attributes.Insert(index + 1, copiedAttribute);
143+
return;
144+
}
145+
}
146+
147+
// Move forward through context.AllAttributes from the provided index until we find a familiar attribute in
148+
// tagHelperOutput where we can insert the copied value.
149+
for (var i = allAttributeIndex + 1; i < context.AllAttributes.Count; i++)
150+
{
151+
var nextName = context.AllAttributes[i].Name;
152+
var index = IndexOfFirstMatch(nextName, tagHelperOutput.Attributes);
153+
if (index != -1)
154+
{
155+
tagHelperOutput.Attributes.Insert(index, copiedAttribute);
156+
return;
157+
}
158+
}
159+
160+
// Couldn't determine the attribute's location, add it to the end.
161+
tagHelperOutput.Attributes.Add(copiedAttribute);
162+
}
163+
164+
private static int IndexOfFirstMatch(string name, TagHelperAttributeList attributes)
165+
{
166+
for (var i = 0; i < attributes.Count; i++)
167+
{
168+
if (string.Equals(name, attributes[i].Name, StringComparison.OrdinalIgnoreCase))
169+
{
170+
return i;
171+
}
172+
}
173+
174+
return -1;
175+
}
103176
}
104177
}

test/Microsoft.AspNet.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Customer.Index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<form action="/Customer/HtmlGeneration_Customer" method="post">
44
<div>
55
<label class="order" for="Number">Number</label>
6-
<input class="form-control input-validation-error" type="number" data-val="true" data-val-range="The field Number must be between 1 and 100." data-val-range-max="100" data-val-range-min="1" data-val-required="The Number field is required." id="Number" name="Number" value="" />
6+
<input type="number" class="form-control input-validation-error" data-val="true" data-val-range="The field Number must be between 1 and 100." data-val-range-max="100" data-val-range-min="1" data-val-required="The Number field is required." id="Number" name="Number" value="" />
77
<span class="field-validation-error" data-valmsg-for="Number" data-valmsg-replace="true">The value &#x27;&#x27; is invalid.</span>
88
</div>
99
<div>
@@ -21,7 +21,7 @@
2121
</div>
2222
<div>
2323
<label class="order" for="Password">Password</label>
24-
<input class="form-control input-validation-error" type="password" data-val="true" data-val-required="The Password field is required." id="Password" name="Password" />
24+
<input type="password" class="form-control input-validation-error" data-val="true" data-val-required="The Password field is required." id="Password" name="Password" />
2525
<span class="field-validation-error" data-valmsg-for="Password" data-valmsg-replace="true">The Password field is required.</span>
2626
</div>
2727
<div>

test/Microsoft.AspNet.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.CreateWarehouse.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<body>
33
<form action="/HtmlGeneration_Home/CreateWarehouse" method="post"> <div>
44
<label class="warehouse" for="City">City</label>
5-
<input size="50" type="text" data-val="true" data-val-minlength="The field City must be a string or array type with a minimum length of &#x27;2&#x27;." data-val-minlength-min="2" id="City" name="City" value="" />
5+
<input type="text" size="50" data-val="true" data-val-minlength="The field City must be a string or array type with a minimum length of &#x27;2&#x27;." data-val-minlength-min="2" id="City" name="City" value="" />
66
<span class="field-validation-valid" data-valmsg-for="City" data-valmsg-replace="true"></span>
77
</div>
88
<div>

test/Microsoft.AspNet.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Customer.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<form action="/Customer/HtmlGeneration_Customer" method="post">
44
<div>
55
<label class="order" for="Number">Number</label>
6-
<input class="form-control" type="number" data-val="true" data-val-range="The field Number must be between 1 and 100." data-val-range-max="100" data-val-range-min="1" data-val-required="The Number field is required." id="Number" name="Number" value="" />
6+
<input type="number" class="form-control" data-val="true" data-val-range="The field Number must be between 1 and 100." data-val-range-max="100" data-val-range-min="1" data-val-required="The Number field is required." id="Number" name="Number" value="" />
77
<span class="field-validation-valid" data-valmsg-for="Number" data-valmsg-replace="true"></span>
88
</div>
99
<div>
@@ -21,7 +21,7 @@
2121
</div>
2222
<div>
2323
<label class="order" for="Password">Password</label>
24-
<input class="form-control" type="password" data-val="true" data-val-required="The Password field is required." id="Password" name="Password" />
24+
<input type="password" class="form-control" data-val="true" data-val-required="The Password field is required." id="Password" name="Password" />
2525
<span class="field-validation-valid" data-valmsg-for="Password" data-valmsg-replace="true"></span>
2626
</div>
2727
<div>

test/Microsoft.AspNet.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.EmployeeList.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<div>
55
<label for="z0__Number">Number</label>
66
<input data-val="true" data-val-range="The field Number must be between 1 and 100." data-val-range-max="100" data-val-range-min="1" data-val-required="The Number field is required." disabled="disabled" id="z0__Number" name="[0].Number" readonly="readonly" type="text" value="0" />
7-
<input class="form-control" type="number" id="z0__Number" name="[0].Number" value="0" />
7+
<input type="number" class="form-control" id="z0__Number" name="[0].Number" value="0" />
88
</div>
99
<div>
1010
<label class="employee" for="z0__Name">Name</label>
@@ -37,7 +37,7 @@
3737
<div>
3838
<label for="z1__Number">Number</label>
3939
<input data-val="true" data-val-range="The field Number must be between 1 and 100." data-val-range-max="100" data-val-range-min="1" data-val-required="The Number field is required." disabled="disabled" id="z1__Number" name="[1].Number" readonly="readonly" type="text" value="1" />
40-
<input class="form-control" type="number" id="z1__Number" name="[1].Number" value="1" />
40+
<input type="number" class="form-control" id="z1__Number" name="[1].Number" value="1" />
4141
</div>
4242
<div>
4343
<label class="employee" for="z1__Name">Name</label>
@@ -70,7 +70,7 @@
7070
<div>
7171
<label for="z2__Number">Number</label>
7272
<input data-val="true" data-val-range="The field Number must be between 1 and 100." data-val-range-max="100" data-val-range-min="1" data-val-required="The Number field is required." disabled="disabled" id="z2__Number" name="[2].Number" readonly="readonly" type="text" value="2" />
73-
<input class="form-control" type="number" id="z2__Number" name="[2].Number" value="2" />
73+
<input type="number" class="form-control" id="z2__Number" name="[2].Number" value="2" />
7474
</div>
7575
<div>
7676
<label class="employee" for="z2__Name">Name</label>

test/Microsoft.AspNet.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Image.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ <h2>Image Tag Helper Test</h2>
1515
<img alt="Red versioned" title="Red versioned" src="/images/red.png?v=W2F5D366_nQ2fQqUk3URdgWy2ZekXjHzHJaY5yaiOOk" />
1616

1717
<!-- Plain image tag with file version set to false -->
18-
<img alt="Red explicitly not versioned" title="Red versioned" src="/images/red.png">
18+
<img src="/images/red.png" alt="Red explicitly not versioned" title="Red versioned">
1919

2020
<!-- Plain image tag with absolute path and file version -->
2121
<img alt="Absolute path versioned" src="http://contoso.com/hello/world">

0 commit comments

Comments
 (0)