From 3002f4f438c2224b3be37a46246f9ff212564a7a Mon Sep 17 00:00:00 2001 From: Pranav K Date: Tue, 28 Nov 2017 17:52:22 -0800 Subject: [PATCH] Add a tag helper Fixes #5916 --- .../PartialTagHelper.cs | 66 +++++++++ .../ViewFeatures/DefaultHtmlGenerator.cs | 133 +++++++++++------- .../ViewFeatures/HtmlHelper.cs | 48 +------ .../ViewFeatures/IHtmlGenerator.cs | 23 +++ .../breakingchanges.netcore.json | 12 ++ .../HtmlGenerationTest.cs | 2 + ...ation_Home.ProductListUsingTagHelpers.html | 64 +++++++++ .../PartialTagHelperTest.cs | 81 +++++++++++ .../TestableHtmlGenerator.cs | 9 +- .../HtmlGeneratorUtilities.cs | 12 +- .../Rendering/DefaultTemplatesUtilities.cs | 10 +- .../ViewFeatures/DefaultHtmlGeneratorTest.cs | 58 +++++++- .../HtmlGeneration_HomeController.cs | 2 + .../ProductListUsingTagHelpers.cshtml | 30 ++++ 14 files changed, 447 insertions(+), 103 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.ViewFeatures/breakingchanges.netcore.json create mode 100644 test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductListUsingTagHelpers.html create mode 100644 test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs create mode 100644 test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/ProductListUsingTagHelpers.cshtml diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs new file mode 100644 index 0000000000..7b7ba340e3 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs @@ -0,0 +1,66 @@ +// Copyright (c) .NET Foundation. 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.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Microsoft.AspNetCore.Mvc.TagHelpers +{ + /// + /// Renders a partial view. + /// + [HtmlTargetElement("partial", Attributes = "name", TagStructure = TagStructure.WithoutEndTag)] + public class PartialTagHelper : TagHelper + { + private readonly IHtmlGenerator _htmlGenerator; + private readonly IViewBufferScope _viewBufferScope; + + public PartialTagHelper( + IHtmlGenerator htmlGenerator, + IViewBufferScope viewBufferScope) + { + _htmlGenerator = htmlGenerator ?? throw new ArgumentNullException(nameof(htmlGenerator)); + _viewBufferScope = viewBufferScope ?? throw new ArgumentNullException(nameof(viewBufferScope)); + } + + /// + /// The name of the partial view used to create the HTML markup. Must not be null. + /// + public string Name { get; set; } + + /// + /// A model to pass into the partial view. + /// + public object Model { get; set; } + + /// + /// A to pass into the partial view. + /// + public ViewDataDictionary ViewData { get; set; } + + [ViewContext] + public ViewContext ViewContext { get; set; } + + public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + var viewBuffer = new ViewBuffer(_viewBufferScope, Name, ViewBuffer.PartialViewPageSize); + using (var writer = new ViewBufferTextWriter(viewBuffer, Encoding.UTF8)) + { + await _htmlGenerator.RenderPartialViewAsync( + ViewContext, + Name, + Model, + ViewData, + writer); + + output.SuppressOutput(); + output.Content.SetHtmlContent(viewBuffer); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs index 0b9a75e282..41f60d96e0 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/DefaultHtmlGenerator.cs @@ -6,15 +6,18 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; +using System.IO; using System.Linq; using System.Reflection; using System.Text.Encodings.Web; +using System.Threading.Tasks; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Internal; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal; using Microsoft.Extensions.Options; @@ -35,6 +38,7 @@ public class DefaultHtmlGenerator : IHtmlGenerator private readonly IUrlHelperFactory _urlHelperFactory; private readonly HtmlEncoder _htmlEncoder; private readonly ValidationHtmlAttributeProvider _validationAttributeProvider; + private readonly ICompositeViewEngine _viewEngine; /// /// Initializes a new instance of the class. @@ -46,49 +50,27 @@ public class DefaultHtmlGenerator : IHtmlGenerator /// The . /// The . /// The . + /// The . public DefaultHtmlGenerator( IAntiforgery antiforgery, IOptions optionsAccessor, IModelMetadataProvider metadataProvider, IUrlHelperFactory urlHelperFactory, HtmlEncoder htmlEncoder, - ValidationHtmlAttributeProvider validationAttributeProvider) + ValidationHtmlAttributeProvider validationAttributeProvider, + ICompositeViewEngine viewEngine) { - if (antiforgery == null) - { - throw new ArgumentNullException(nameof(antiforgery)); - } - if (optionsAccessor == null) { throw new ArgumentNullException(nameof(optionsAccessor)); } - if (metadataProvider == null) - { - throw new ArgumentNullException(nameof(metadataProvider)); - } - - if (urlHelperFactory == null) - { - throw new ArgumentNullException(nameof(urlHelperFactory)); - } - - if (htmlEncoder == null) - { - throw new ArgumentNullException(nameof(htmlEncoder)); - } - - if (validationAttributeProvider == null) - { - throw new ArgumentNullException(nameof(validationAttributeProvider)); - } - - _antiforgery = antiforgery; - _metadataProvider = metadataProvider; - _urlHelperFactory = urlHelperFactory; - _htmlEncoder = htmlEncoder; - _validationAttributeProvider = validationAttributeProvider; + _antiforgery = antiforgery ?? throw new ArgumentNullException(nameof(antiforgery)); + _metadataProvider = metadataProvider ?? throw new ArgumentNullException(nameof(metadataProvider)); + _urlHelperFactory = urlHelperFactory ?? throw new ArgumentNullException(nameof(urlHelperFactory)); + _htmlEncoder = htmlEncoder ?? throw new ArgumentNullException(nameof(htmlEncoder)); + _validationAttributeProvider = validationAttributeProvider ?? throw new ArgumentNullException(nameof(validationAttributeProvider)); + _viewEngine = viewEngine; // Underscores are fine characters in id's. IdAttributeDotReplacement = optionsAccessor.Value.HtmlHelperOptions.IdAttributeDotReplacement; @@ -214,8 +196,7 @@ public virtual TagBuilder GenerateCheckBox( if (modelExplorer.Model != null) { - bool modelChecked; - if (bool.TryParse(modelExplorer.Model.ToString(), out modelChecked)) + if (bool.TryParse(modelExplorer.Model.ToString(), out var modelChecked)) { isChecked = modelChecked; } @@ -363,8 +344,7 @@ public virtual TagBuilder GenerateHidden( } // Special-case opaque values and arbitrary binary data. - var byteArrayValue = value as byte[]; - if (byteArrayValue != null) + if (value is byte[] byteArrayValue) { value = Convert.ToBase64String(byteArrayValue); } @@ -631,8 +611,7 @@ public virtual TagBuilder GenerateSelect( } // If there are any errors for a named field, we add the css attribute. - ModelStateEntry entry; - if (viewContext.ViewData.ModelState.TryGetValue(fullName, out entry)) + if (viewContext.ViewData.ModelState.TryGetValue(fullName, out var entry)) { if (entry.Errors.Count > 0) { @@ -684,8 +663,7 @@ public virtual TagBuilder GenerateTextArea( nameof(expression)); } - ModelStateEntry entry; - viewContext.ViewData.ModelState.TryGetValue(fullName, out entry); + viewContext.ViewData.ModelState.TryGetValue(fullName, out var entry); var value = string.Empty; if (entry != null && entry.AttemptedValue != null) @@ -791,8 +769,7 @@ public virtual TagBuilder GenerateValidationMessage( return null; } - ModelStateEntry entry; - var tryGetModelStateResult = viewContext.ViewData.ModelState.TryGetValue(fullName, out entry); + var tryGetModelStateResult = viewContext.ViewData.ModelState.TryGetValue(fullName, out var entry); var modelErrors = tryGetModelStateResult ? entry.Errors : null; ModelError modelError = null; @@ -868,9 +845,8 @@ public virtual TagBuilder GenerateValidationSummary( return null; } - ModelStateEntry entryForModel; if (excludePropertyErrors && - (!viewData.ModelState.TryGetValue(viewData.TemplateInfo.HtmlFieldPrefix, out entryForModel) || + (!viewData.ModelState.TryGetValue(viewData.TemplateInfo.HtmlFieldPrefix, out var entryForModel) || entryForModel.Errors.Count == 0)) { // Client-side validation (if enabled) will not affect the generated element and element will be empty. @@ -1095,6 +1071,65 @@ public virtual ICollection GetCurrentValues( return currentValues; } + /// + public async Task RenderPartialViewAsync( + ViewContext viewContext, + string partialViewName, + object model, + ViewDataDictionary viewData, + TextWriter writer) + { + if (viewContext == null) + { + throw new ArgumentNullException(nameof(viewContext)); + } + + if (partialViewName == null) + { + throw new ArgumentNullException(nameof(partialViewName)); + } + + if (writer == null) + { + throw new ArgumentNullException(nameof(writer)); + } + + var viewEngineResult = _viewEngine.GetView( + viewContext.ExecutingFilePath, + partialViewName, + isMainPage: false); + var getViewLocations = viewEngineResult.SearchedLocations; + if (!viewEngineResult.Success) + { + viewEngineResult = _viewEngine.FindView(viewContext, partialViewName, isMainPage: false); + } + + if (!viewEngineResult.Success) + { + var searchedLocations = Enumerable.Concat(getViewLocations, viewEngineResult.SearchedLocations); + var locations = string.Empty; + if (searchedLocations.Any()) + { + locations += Environment.NewLine + string.Join(Environment.NewLine, searchedLocations); + } + + throw new InvalidOperationException( + Resources.FormatViewEngine_PartialViewNotFound(partialViewName, locations)); + } + + var view = viewEngineResult.View; + using (view as IDisposable) + { + // Determine which ViewData we should use to construct a new ViewData + var baseViewData = viewData ?? viewContext.ViewData; + + var newViewData = new ViewDataDictionary(baseViewData, model); + var partialViewContext = new ViewContext(viewContext, view, newViewData, writer); + + await viewEngineResult.View.RenderAsync(partialViewContext); + } + } + internal static string EvalString(ViewContext viewContext, string key, string format) { return Convert.ToString(viewContext.ViewData.Eval(key, format), CultureInfo.CurrentCulture); @@ -1133,8 +1168,7 @@ internal static TagBuilder GenerateOption(SelectListItem item, string text, bool internal static object GetModelStateValue(ViewContext viewContext, string key, Type destinationType) { - ModelStateEntry entry; - if (viewContext.ViewData.ModelState.TryGetValue(key, out entry) && entry.RawValue != null) + if (viewContext.ViewData.ModelState.TryGetValue(key, out var entry) && entry.RawValue != null) { return ModelBindingHelper.ConvertTo(entry.RawValue, destinationType, culture: null); } @@ -1249,8 +1283,7 @@ protected virtual TagBuilder GenerateInput( case InputType.Radio: if (!usedModelState) { - var modelStateValue = GetModelStateValue(viewContext, fullName, typeof(string)) as string; - if (modelStateValue != null) + if (GetModelStateValue(viewContext, fullName, typeof(string)) is string modelStateValue) { isChecked = string.Equals(modelStateValue, valueParameter, StringComparison.Ordinal); usedModelState = true; @@ -1313,8 +1346,7 @@ protected virtual TagBuilder GenerateInput( } // If there are any errors for a named field, we add the CSS attribute. - ModelStateEntry entry; - if (viewContext.ViewData.ModelState.TryGetValue(fullName, out entry) && entry.Errors.Count > 0) + if (viewContext.ViewData.ModelState.TryGetValue(fullName, out var entry) && entry.Errors.Count > 0) { tagBuilder.AddCssClass(HtmlHelper.ValidationInputCssClassName); } @@ -1410,8 +1442,7 @@ private static Enum ConvertEnumFromInteger(object value, Type targetType) private static object ConvertEnumFromString(string value) where TEnum : struct { - TEnum enumValue; - if (Enum.TryParse(value, out enumValue)) + if (Enum.TryParse(value, out TEnum enumValue)) { return enumValue; } diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelper.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelper.cs index 6233cb4d94..f184f6942e 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/HtmlHelper.cs @@ -160,8 +160,7 @@ public static IDictionary ObjectToDictionary(object value) /// public static IDictionary AnonymousObjectToHtmlAttributes(object htmlAttributes) { - var dictionary = htmlAttributes as IDictionary; - if (dictionary != null) + if (htmlAttributes is IDictionary dictionary) { return new Dictionary(dictionary, StringComparer.OrdinalIgnoreCase); } @@ -504,7 +503,7 @@ protected virtual IHtmlContent GenerateDisplay( return templateBuilder.Build(); } - protected virtual async Task RenderPartialCoreAsync( + protected virtual Task RenderPartialCoreAsync( string partialViewName, object model, ViewDataDictionary viewData, @@ -515,45 +514,12 @@ protected virtual async Task RenderPartialCoreAsync( throw new ArgumentNullException(nameof(partialViewName)); } - var viewEngineResult = _viewEngine.GetView( - ViewContext.ExecutingFilePath, + return _htmlGenerator.RenderPartialViewAsync( + ViewContext, partialViewName, - isMainPage: false); - var originalLocations = viewEngineResult.SearchedLocations; - if (!viewEngineResult.Success) - { - viewEngineResult = _viewEngine.FindView(ViewContext, partialViewName, isMainPage: false); - } - - if (!viewEngineResult.Success) - { - var locations = string.Empty; - if (originalLocations.Any()) - { - locations = Environment.NewLine + string.Join(Environment.NewLine, originalLocations); - } - - if (viewEngineResult.SearchedLocations.Any()) - { - locations += - Environment.NewLine + string.Join(Environment.NewLine, viewEngineResult.SearchedLocations); - } - - throw new InvalidOperationException( - Resources.FormatViewEngine_PartialViewNotFound(partialViewName, locations)); - } - - var view = viewEngineResult.View; - using (view as IDisposable) - { - // Determine which ViewData we should use to construct a new ViewData - var baseViewData = viewData ?? ViewData; - - var newViewData = new ViewDataDictionary(baseViewData, model); - var viewContext = new ViewContext(ViewContext, view, newViewData, writer); - - await viewEngineResult.View.RenderAsync(viewContext); - } + model, + viewData, + writer); } /// diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/IHtmlGenerator.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/IHtmlGenerator.cs index f0f59d4ba2..201c55058e 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/IHtmlGenerator.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/ViewFeatures/IHtmlGenerator.cs @@ -2,6 +2,8 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Mvc.Rendering; @@ -481,5 +483,26 @@ ICollection GetCurrentValues( ModelExplorer modelExplorer, string expression, bool allowMultiple); + + /// + /// Renders HTML markup for the specified partial view. + /// + /// + /// The name of the partial view used to create the HTML markup. Must not be null. + /// + /// A instance for the current scope. + /// A model to pass into the partial view. + /// A to pass into the partial view. + /// The to render to. + /// A that renders the created HTML when it executes. + /// + /// In this context, "renders" means the method writes its output using . + /// + Task RenderPartialViewAsync( + ViewContext viewContext, + string partialViewName, + object model, + ViewDataDictionary viewData, + TextWriter writer); } } diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/breakingchanges.netcore.json b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/breakingchanges.netcore.json new file mode 100644 index 0000000000..4801f77ca8 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/breakingchanges.netcore.json @@ -0,0 +1,12 @@ +[ + { + "TypeId": "public class Microsoft.AspNetCore.Mvc.ViewFeatures.DefaultHtmlGenerator : Microsoft.AspNetCore.Mvc.ViewFeatures.IHtmlGenerator", + "MemberId": "public .ctor(Microsoft.AspNetCore.Antiforgery.IAntiforgery antiforgery, Microsoft.Extensions.Options.IOptions optionsAccessor, Microsoft.AspNetCore.Mvc.ModelBinding.IModelMetadataProvider metadataProvider, Microsoft.AspNetCore.Mvc.Routing.IUrlHelperFactory urlHelperFactory, System.Text.Encodings.Web.HtmlEncoder htmlEncoder, Microsoft.AspNetCore.Mvc.ViewFeatures.ValidationHtmlAttributeProvider validationAttributeProvider)", + "Kind": "Removal" + }, + { + "TypeId": "public interface Microsoft.AspNetCore.Mvc.ViewFeatures.IHtmlGenerator", + "MemberId": "System.Threading.Tasks.Task RenderPartialViewAsync(Microsoft.AspNetCore.Mvc.Rendering.ViewContext viewContext, System.String partialViewName, System.Object model, Microsoft.AspNetCore.Mvc.ViewFeatures.ViewDataDictionary viewData, System.IO.TextWriter writer)", + "Kind": "Addition" + } +] diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs index 1579544e7c..49b9455779 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs @@ -65,6 +65,8 @@ public static TheoryData WebPagesData { "ProductList", "/HtmlGeneration_Product" }, // Testing the ScriptTagHelper { "Script", null }, + // Testing PartialTagHelper + InputTagHelpers + { "ProductListUsingTagHelpers", "/HtmlGeneration_Product" }, }; return data; diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductListUsingTagHelpers.html b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductListUsingTagHelpers.html new file mode 100644 index 0000000000..afa2cc3bc6 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.ProductListUsingTagHelpers.html @@ -0,0 +1,64 @@ + + + + + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs new file mode 100644 index 0000000000..ac7723b062 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs @@ -0,0 +1,81 @@ +// Copyright (c) .NET Foundation. 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 System.IO; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.TestCommon; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.Extensions.WebEncoders.Testing; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.TagHelpers +{ + public class PartialTagHelperTest + { + [Fact] + public async Task ProcessAsync_RendersPartialView() + { + // Arrange + var expected = "Hello world!"; + var bufferScope = new TestViewBufferScope(); + var generator = new Mock(); + var partialName = "_Partial"; + var model = new object(); + var viewData = new ViewDataDictionary(new TestModelMetadataProvider(), new ModelStateDictionary()); + var viewContext = new ViewContext + { + ViewData = new ViewDataDictionary(new TestModelMetadataProvider(), new ModelStateDictionary()), + }; + + generator.Setup(m => m.RenderPartialViewAsync(viewContext, partialName, model, viewData, It.IsAny())) + .Callback((ViewContext _, string __, object ___, ViewDataDictionary ____, TextWriter writer) => + { + writer.Write(expected); + }) + .Returns(Task.CompletedTask) + .Verifiable(); + + var tagHelper = new PartialTagHelper(generator.Object, bufferScope) + { + Name = partialName, + Model = model, + ViewContext = viewContext, + ViewData = viewData, + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + generator.Verify(); + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } + + private static TagHelperContext GetTagHelperContext() + { + return new TagHelperContext( + "partial", + new TagHelperAttributeList(), + new Dictionary(), + Guid.NewGuid().ToString("N")); + } + + private static TagHelperOutput GetTagHelperOutput() + { + return new TagHelperOutput( + "partial", + new TagHelperAttributeList(), + (_, __) => Task.FromResult(new DefaultTagHelperContent())); + } + } +} diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TestableHtmlGenerator.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TestableHtmlGenerator.cs index daf8314ff5..1ce8da099d 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TestableHtmlGenerator.cs +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/TestableHtmlGenerator.cs @@ -35,7 +35,8 @@ public TestableHtmlGenerator(IModelMetadataProvider metadataProvider, IUrlHelper metadataProvider, GetOptions(), urlHelper, - validationAttributes: new Dictionary(StringComparer.OrdinalIgnoreCase)) + validationAttributes: new Dictionary(StringComparer.OrdinalIgnoreCase), + viewEngine: Mock.Of()) { } @@ -43,14 +44,16 @@ public TestableHtmlGenerator( IModelMetadataProvider metadataProvider, IOptions options, IUrlHelper urlHelper, - IDictionary validationAttributes) + IDictionary validationAttributes, + ICompositeViewEngine viewEngine) : base( Mock.Of(), options, metadataProvider, CreateUrlHelperFactory(urlHelper), new HtmlTestEncoder(), - new DefaultValidationHtmlAttributeProvider(options, metadataProvider, new ClientValidatorCache())) + new DefaultValidationHtmlAttributeProvider(options, metadataProvider, new ClientValidatorCache()), + viewEngine) { _validationAttributes = validationAttributes; } diff --git a/test/Microsoft.AspNetCore.Mvc.TestCommon/HtmlGeneratorUtilities.cs b/test/Microsoft.AspNetCore.Mvc.TestCommon/HtmlGeneratorUtilities.cs index a40024c7e4..b57f114311 100644 --- a/test/Microsoft.AspNetCore.Mvc.TestCommon/HtmlGeneratorUtilities.cs +++ b/test/Microsoft.AspNetCore.Mvc.TestCommon/HtmlGeneratorUtilities.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Mvc.ViewEngines; using Microsoft.Extensions.Options; using Microsoft.Extensions.WebEncoders.Testing; using Moq; @@ -21,10 +22,14 @@ public static IHtmlGenerator GetHtmlGenerator(IModelMetadataProvider provider) .Setup(f => f.GetUrlHelper(It.IsAny())) .Returns(Mock.Of()); - return GetHtmlGenerator(provider, urlHelperFactory.Object, options); + return GetHtmlGenerator(provider, urlHelperFactory.Object, options, Mock.Of()); } - public static IHtmlGenerator GetHtmlGenerator(IModelMetadataProvider provider, IUrlHelperFactory urlHelperFactory, MvcViewOptions options) + public static IHtmlGenerator GetHtmlGenerator( + IModelMetadataProvider provider, + IUrlHelperFactory urlHelperFactory, + MvcViewOptions options, + ICompositeViewEngine viewEngine) { var optionsAccessor = new Mock>(); optionsAccessor @@ -42,7 +47,8 @@ public static IHtmlGenerator GetHtmlGenerator(IModelMetadataProvider provider, I provider, urlHelperFactory, new HtmlTestEncoder(), - attributeProvider); + attributeProvider, + viewEngine); return htmlGenerator; } } diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/DefaultTemplatesUtilities.cs b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/DefaultTemplatesUtilities.cs index 75649f3223..390192b5c6 100644 --- a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/DefaultTemplatesUtilities.cs +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Rendering/DefaultTemplatesUtilities.cs @@ -247,7 +247,11 @@ private static HtmlHelper GetHtmlHelper( if (htmlGenerator == null) { - htmlGenerator = HtmlGeneratorUtilities.GetHtmlGenerator(provider, urlHelperFactory.Object, options); + htmlGenerator = HtmlGeneratorUtilities.GetHtmlGenerator( + provider, + urlHelperFactory.Object, + options, + viewEngine); } // TemplateRenderer will Contextualize this transient service. @@ -298,7 +302,7 @@ private static HtmlHelper GetHtmlHelper( return htmlHelper; } - private static ICompositeViewEngine CreateViewEngine() + public static ICompositeViewEngine CreateViewEngine() { var view = new Mock(); view @@ -329,7 +333,7 @@ public static string FormatOutput(IHtmlHelper helper, object model) return FormatOutput(modelExplorer); } - private static string FormatOutput(ModelExplorer modelExplorer) + public static string FormatOutput(ModelExplorer modelExplorer) { var metadata = modelExplorer.Metadata; return string.Format( diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs index 3df4cf0438..7514fa68aa 100644 --- a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewFeatures/DefaultHtmlGeneratorTest.cs @@ -6,6 +6,7 @@ using System.IO; using System.Linq; using System.Text.Encodings.Web; +using System.Threading.Tasks; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Abstractions; @@ -676,8 +677,58 @@ public void GenerateAntiforgery_AlwaysGeneratesAntiforgeryToken_IfCannotRenderAt Assert.Equal(expected, antiforgeryField); } + [Fact] + public async Task RenderPartialViewAsync_Throws_IfViewCannotBeFound() + { + // Arrange + var expected = "The partial view 'test-view' was not found. The following locations were searched:" + + Environment.NewLine + + "location1" + Environment.NewLine + + "location2" + Environment.NewLine + + "location3" + Environment.NewLine + + "location4"; + + var viewEngine = new Mock(MockBehavior.Strict); + viewEngine + .Setup(v => v.GetView(/*executingFilePath*/ null, It.IsAny(), /*isMainPage*/ false)) + .Returns(ViewEngineResult.NotFound("test-view", new[] { "location1", "location2" })) + .Verifiable(); + viewEngine + .Setup(v => v.FindView(It.IsAny(), It.IsAny(), /*isMainPage*/ false)) + .Returns(ViewEngineResult.NotFound("test-view", new[] { "location3", "location4" })) + .Verifiable(); + var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var htmlGenerator = GetGenerator(metadataProvider, viewEngine.Object); + var writer = new StringWriter(); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => htmlGenerator.RenderPartialViewAsync(new ViewContext(), "test-view", null, null, writer)); + Assert.Equal(expected, exception.Message); + } + + [Fact] + public async Task RenderPartialAsync_RendersViewToWriter() + { + // Arrange + var model = new Model(); + var metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var expected = DefaultTemplatesUtilities.FormatOutput(metadataProvider.GetModelExplorerForType(model.GetType(), model)); + var htmlGenerator = GetGenerator(metadataProvider); + var writer = new StringWriter(); + var viewContext = GetViewContext(model: null, metadataProvider: metadataProvider); + + // Act + await htmlGenerator.RenderPartialViewAsync(viewContext, "test-view", model, null, writer); + + // Assert + Assert.Equal(expected, writer.ToString()); + } + // GetCurrentValues uses only the IModelMetadataProvider passed to the DefaultHtmlGenerator constructor. - private static IHtmlGenerator GetGenerator(IModelMetadataProvider metadataProvider) + private static IHtmlGenerator GetGenerator( + IModelMetadataProvider metadataProvider, + ICompositeViewEngine viewEngine = null) { var mvcViewOptionsAccessor = new Mock>(); mvcViewOptionsAccessor.SetupGet(accessor => accessor.Value).Returns(new MvcViewOptions()); @@ -696,13 +747,16 @@ private static IHtmlGenerator GetGenerator(IModelMetadataProvider metadataProvid metadataProvider, new ClientValidatorCache()); + viewEngine = viewEngine ?? DefaultTemplatesUtilities.CreateViewEngine(); + return new DefaultHtmlGenerator( antiforgery.Object, mvcViewOptionsAccessor.Object, metadataProvider, new UrlHelperFactory(), htmlEncoder, - attributeProvider); + attributeProvider, + viewEngine); } // GetCurrentValues uses only the ModelStateDictionary and ViewDataDictionary from the passed ViewContext. diff --git a/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs b/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs index 3736f9554c..3402b1fbdf 100644 --- a/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs +++ b/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs @@ -108,6 +108,8 @@ public IActionResult ProductList() return View(_products); } + public IActionResult ProductListUsingTagHelpers() => View(_products); + public IActionResult EmployeeList() { var employees = new List diff --git a/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/ProductListUsingTagHelpers.cshtml b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/ProductListUsingTagHelpers.cshtml new file mode 100644 index 0000000000..e5ad50be52 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/ProductListUsingTagHelpers.cshtml @@ -0,0 +1,30 @@ +@using HtmlGenerationWebSite.Models +@model IEnumerable + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + + + + + + +
+ @{ + var index = 0; + var fieldPrefix = ViewData.TemplateInfo.HtmlFieldPrefix; + foreach (var product in Model) + { + @* Update HtmlFieldPrefix so generated for, id and name attribute values are correct. *@ + ViewData.TemplateInfo.HtmlFieldPrefix = fieldPrefix + string.Format("[{0}]", index++); +
+ + +
+ + ViewData.TemplateInfo.HtmlFieldPrefix = fieldPrefix; + } + + } + + +