From 6ed589f47d2ff04cf5be13455b96315a2778d00d 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 | 113 ++++++ .../Properties/Resources.Designer.cs | 14 + .../Resources.resx | 3 + ...ation_Home.ProductListUsingTagHelpers.html | 64 +++ .../PartialTagHelperTest.cs | 382 ++++++++++++++++++ .../HtmlGeneration_HomeController.cs | 2 + .../ProductListUsingTagHelpers.cshtml | 30 ++ 7 files changed, 608 insertions(+) create mode 100644 src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs 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..1a04a61ff2 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs @@ -0,0 +1,113 @@ +// 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.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; +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 ICompositeViewEngine _viewEngine; + private readonly IViewBufferScope _viewBufferScope; + + public PartialTagHelper( + ICompositeViewEngine viewEngine, + IViewBufferScope viewBufferScope) + { + _viewEngine = viewEngine ?? throw new ArgumentNullException(nameof(viewEngine)); + _viewBufferScope = viewBufferScope ?? throw new ArgumentNullException(nameof(viewBufferScope)); + } + + /// + /// The name of the partial view used to create the HTML markup. + /// + 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; } + + [HtmlAttributeNotBound] + [ViewContext] + public ViewContext ViewContext { get; set; } + + /// + public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + if (output == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var viewBuffer = new ViewBuffer(_viewBufferScope, Name, ViewBuffer.PartialViewPageSize); + using (var writer = new ViewBufferTextWriter(viewBuffer, Encoding.UTF8)) + { + await RenderPartialViewAsync(writer); + + // Reset the TagName. We don't want `partial` to render. + output.TagName = null; + output.Content.SetHtmlContent(viewBuffer); + } + } + + private async Task RenderPartialViewAsync(TextWriter writer) + { + var viewEngineResult = _viewEngine.GetView(ViewContext.ExecutingFilePath, Name, isMainPage: false); + var getViewLocations = viewEngineResult.SearchedLocations; + if (!viewEngineResult.Success) + { + viewEngineResult = _viewEngine.FindView(ViewContext, Name, 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(Name, 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 model = Model ?? ViewContext.ViewData.Model; + + var newViewData = new ViewDataDictionary(baseViewData, model); + var partialViewContext = new ViewContext(ViewContext, view, newViewData, writer); + + await view.RenderAsync(partialViewContext); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs index 411940c2a9..c590669745 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs @@ -178,6 +178,20 @@ internal static string ArgumentCannotContainHtmlSpace internal static string FormatArgumentCannotContainHtmlSpace() => GetString("ArgumentCannotContainHtmlSpace"); + /// + /// The partial view '{0}' was not found. The following locations were searched:{1} + /// + internal static string ViewEngine_PartialViewNotFound + { + get => GetString("ViewEngine_PartialViewNotFound"); + } + + /// + /// The partial view '{0}' was not found. The following locations were searched:{1} + /// + internal static string FormatViewEngine_PartialViewNotFound(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("ViewEngine_PartialViewNotFound"), p0, p1); + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx index 7242ee001b..e93c480395 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx @@ -153,4 +153,7 @@ Value cannot contain HTML space characters. + + The partial view '{0}' was not found. The following locations were searched:{1} + \ No newline at end of file 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..54c58cc8aa --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs @@ -0,0 +1,382 @@ +// 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.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.TestCommon; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal; +using Microsoft.AspNetCore.Razor.TagHelpers; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.WebEncoders.Testing; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.TagHelpers +{ + public class PartialTagHelperTest + { + [Fact] + public async Task ProcessAsync_RendersPartialView_IfGetViewReturnsView() + { + // Arrange + var expected = "Hello world!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var model = new object(); + var viewContext = GetViewContext(); + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write(expected); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.Found(partialName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + Model = model, + ViewContext = viewContext, + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } + + [Fact] + public async Task ProcessAsync_RendersPartialView_IfFindViewReturnsView() + { + // Arrange + var expected = "Hello world!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var model = new object(); + var viewContext = GetViewContext(); + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write(expected); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { partialName })); + + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.Found(partialName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + Model = model, + ViewContext = viewContext, + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } + + [Fact] + public async Task ProcessAsync_UsesViewDataFromContext() + { + // Arrange + var expected = "Hello world!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var model = new object(); + var viewContext = GetViewContext(); + viewContext.ViewData["key"] = expected; + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write(v.ViewData["key"]); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { partialName })); + + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.Found(partialName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + Model = model, + ViewContext = viewContext, + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } + + [Fact] + public async Task ProcessAsync_UsesPassedInViewData_WhenNotNull() + { + // Arrange + var expected = "Hello world!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var model = new object(); + var viewData = new ViewDataDictionary(new TestModelMetadataProvider(), new ModelStateDictionary()); + viewData["key"] = expected; + var viewContext = GetViewContext(); + viewContext.ViewData["key"] = "ViewContext"; + + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write(v.ViewData["key"]); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.Found(partialName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + Model = model, + ViewContext = viewContext, + ViewData = viewData, + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } + + [Fact] + public async Task ProcessAsync_UsesPassedInModel() + { + // Arrange + var expected = "Hello world!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var model = new object(); + var viewContext = GetViewContext(); + viewContext.ViewData.Model = new object(); + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + Assert.Same(model, v.ViewData.Model); + v.Writer.Write(expected); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.Found(partialName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + Model = model, + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } + + [Fact] + public async Task ProcessAsync_UsesModelOnViewContextViewData_WhenModelPropertyIsNull() + { + // Arrange + var expected = "Hello world!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var model = new object(); + var viewContext = GetViewContext(); + viewContext.ViewData.Model = model; + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + Assert.Same(model, v.ViewData.Model); + v.Writer.Write(expected); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.Found(partialName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); + Assert.Equal(expected, content); + } + + [Fact] + public async Task ProcessAsync_DisposesViewInstance() + { + // Arrange + var expected = "Hello world!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var model = new object(); + var viewContext = GetViewContext(); + + var disposable = new Mock(); + disposable.Setup(d => d.Dispose()).Verifiable(); + var view = disposable.As(); + + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write(expected); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.Found(partialName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + Model = model, + ViewContext = viewContext, + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + disposable.Verify(); + } + + [Fact] + public async Task ProcessAsync_Throws_IfGetViewAndFindReturnNotFoundResults() + { + // Arrange + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var expected = string.Join(Environment.NewLine, + $"The partial view '{partialName}' was not found. The following locations were searched:", + "NotFound1", + "NotFound2", + "NotFound3", + "NotFound4"); + var viewData = new ViewDataDictionary(new TestModelMetadataProvider(), new ModelStateDictionary()); + var viewContext = GetViewContext(); + + var view = Mock.Of(); + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { "NotFound1", "NotFound2" })); + + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { $"NotFound3", $"NotFound4" })); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + ViewData = viewData, + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => tagHelper.ProcessAsync(tagHelperContext, output)); + Assert.Equal(expected, exception.Message); + } + + private static ViewContext GetViewContext() + { + return new ViewContext( + new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor()), + NullView.Instance, + new ViewDataDictionary(new TestModelMetadataProvider(), new ModelStateDictionary()), + Mock.Of(), + TextWriter.Null, + new HtmlHelperOptions()); + } + + 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/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..218277c8e4 --- /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; + } + + } + + +