From 6ed589f47d2ff04cf5be13455b96315a2778d00d Mon Sep 17 00:00:00 2001 From: Pranav K Date: Tue, 28 Nov 2017 17:52:22 -0800 Subject: [PATCH 1/2] 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; + } + + } + + + From e5110fe32a2bd94285f5422e8ec5186d8eb03a12 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Thu, 30 Nov 2017 09:19:51 -0800 Subject: [PATCH 2/2] More tests, cleanup etc --- .../PartialTagHelper.cs | 14 +- .../HtmlGenerationTest.cs | 4 + ...ion_Home.PartialTagHelperWithoutModel.html | 1 + ...ation_Home.ProductListUsingTagHelpers.html | 16 +- ...WebSite.HtmlGeneration_Home.Warehouse.html | 15 ++ .../PartialTagHelperTest.cs | 153 ++++++++++++++---- .../HtmlGeneration_HomeController.cs | 18 +++ .../PartialTagHelperWithoutModel.cshtml | 2 + .../ProductListUsingTagHelpers.cshtml | 19 +-- .../HtmlGeneration_Home/Warehouse.cshtml | 5 + .../_EmployeePartial.cshtml | 15 ++ .../Views/Shared/_Partial.cshtml | 1 + 12 files changed, 209 insertions(+), 54 deletions(-) create mode 100644 test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.PartialTagHelperWithoutModel.html create mode 100644 test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Warehouse.html create mode 100644 test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/PartialTagHelperWithoutModel.cshtml create mode 100644 test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/Warehouse.cshtml create mode 100644 test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/_EmployeePartial.cshtml create mode 100644 test/WebSites/HtmlGenerationWebSite/Views/Shared/_Partial.cshtml diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs index 1a04a61ff2..6b9ef8b285 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs @@ -20,6 +20,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlTargetElement("partial", Attributes = "name", TagStructure = TagStructure.WithoutEndTag)] public class PartialTagHelper : TagHelper { + private const string ForAttributeName = "asp-for"; private readonly ICompositeViewEngine _viewEngine; private readonly IViewBufferScope _viewBufferScope; @@ -37,9 +38,10 @@ public PartialTagHelper( public string Name { get; set; } /// - /// A model to pass into the partial view. + /// An expression to be evaluated against the current model. /// - public object Model { get; set; } + [HtmlAttributeName(ForAttributeName)] + public ModelExpression For { get; set; } /// /// A to pass into the partial view. @@ -101,11 +103,15 @@ private async Task RenderPartialViewAsync(TextWriter writer) { // Determine which ViewData we should use to construct a new ViewData var baseViewData = ViewData ?? ViewContext.ViewData; - var model = Model ?? ViewContext.ViewData.Model; - + var model = For?.Model ?? ViewContext.ViewData.Model; var newViewData = new ViewDataDictionary(baseViewData, model); var partialViewContext = new ViewContext(ViewContext, view, newViewData, writer); + if (For?.Name != null) + { + newViewData.TemplateInfo.HtmlFieldPrefix = newViewData.TemplateInfo.GetFullHtmlFieldName(For.Name); + } + await view.RenderAsync(partialViewContext); } } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs index 1579544e7c..db7411fff9 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs @@ -61,8 +61,12 @@ public static TheoryData WebPagesData // Only attribute order should differ. { "Order", "/HtmlGeneration_Order/Submit" }, { "OrderUsingHtmlHelpers", "/HtmlGeneration_Order/Submit" }, + // Testing PartialTagHelper + { "PartialTagHelperWithoutModel", null }, + { "Warehouse", null }, // Testing InputTagHelpers invoked in the partial views { "ProductList", "/HtmlGeneration_Product" }, + { "ProductListUsingTagHelpers", "/HtmlGeneration_Product" }, // Testing the ScriptTagHelper { "Script", null }, }; diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.PartialTagHelperWithoutModel.html b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.PartialTagHelperWithoutModel.html new file mode 100644 index 0000000000..81eed3cee9 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.PartialTagHelperWithoutModel.html @@ -0,0 +1 @@ +PartialTagHelperWithoutModel: Hello from partial \ 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 index afa2cc3bc6..861d7b33a4 100644 --- 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 @@ -5,8 +5,8 @@
- - + +
@@ -23,8 +23,8 @@
- - + +
@@ -41,8 +41,8 @@
- - + +
@@ -58,7 +58,9 @@
+ +
HtmlFieldPrefix =
- + \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Warehouse.html b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Warehouse.html new file mode 100644 index 0000000000..d86cdb95db --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/compiler/resources/HtmlGenerationWebSite.HtmlGeneration_Home.Warehouse.html @@ -0,0 +1,15 @@ +

City_1

+ +
+ + +
+
+ + +
+
+ + +
\ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs index 54c58cc8aa..937e95ad44 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs @@ -48,7 +48,6 @@ public async Task ProcessAsync_RendersPartialView_IfGetViewReturnsView() var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) { Name = partialName, - Model = model, ViewContext = viewContext, }; var tagHelperContext = GetTagHelperContext(); @@ -90,7 +89,6 @@ public async Task ProcessAsync_RendersPartialView_IfFindViewReturnsView() var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) { Name = partialName, - Model = model, ViewContext = viewContext, }; var tagHelperContext = GetTagHelperContext(); @@ -108,10 +106,9 @@ public async Task ProcessAsync_RendersPartialView_IfFindViewReturnsView() public async Task ProcessAsync_UsesViewDataFromContext() { // Arrange - var expected = "Hello world!"; + var expected = "Implicit"; var bufferScope = new TestViewBufferScope(); var partialName = "_Partial"; - var model = new object(); var viewContext = GetViewContext(); viewContext.ViewData["key"] = expected; @@ -133,7 +130,6 @@ public async Task ProcessAsync_UsesViewDataFromContext() var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) { Name = partialName, - Model = model, ViewContext = viewContext, }; var tagHelperContext = GetTagHelperContext(); @@ -151,7 +147,7 @@ public async Task ProcessAsync_UsesViewDataFromContext() public async Task ProcessAsync_UsesPassedInViewData_WhenNotNull() { // Arrange - var expected = "Hello world!"; + var expected = "Explicit"; var bufferScope = new TestViewBufferScope(); var partialName = "_Partial"; var model = new object(); @@ -160,7 +156,6 @@ public async Task ProcessAsync_UsesPassedInViewData_WhenNotNull() var viewContext = GetViewContext(); viewContext.ViewData["key"] = "ViewContext"; - var view = new Mock(); view.Setup(v => v.RenderAsync(It.IsAny())) .Callback((ViewContext v) => @@ -176,7 +171,6 @@ public async Task ProcessAsync_UsesPassedInViewData_WhenNotNull() var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) { Name = partialName, - Model = model, ViewContext = viewContext, ViewData = viewData, }; @@ -192,13 +186,20 @@ public async Task ProcessAsync_UsesPassedInViewData_WhenNotNull() } [Fact] - public async Task ProcessAsync_UsesPassedInModel() + public async Task ProcessAsync_UsesModelExpression_ToDetermineModel() { // Arrange - var expected = "Hello world!"; + var expected = new PropertyModel(); var bufferScope = new TestViewBufferScope(); var partialName = "_Partial"; - var model = new object(); + var modelMetadataProvider = new TestModelMetadataProvider(); + var containerModel = new TestModel { Property = expected }; + var containerModelExplorer = modelMetadataProvider.GetModelExplorerForType( + typeof(TestModel), + containerModel); + var propertyModelExplorer = containerModelExplorer.GetExplorerForProperty(nameof(TestModel.Property)); + + var modelExpression = new ModelExpression("Property", propertyModelExplorer); var viewContext = GetViewContext(); viewContext.ViewData.Model = new object(); @@ -206,10 +207,11 @@ public async Task ProcessAsync_UsesPassedInModel() view.Setup(v => v.RenderAsync(It.IsAny())) .Callback((ViewContext v) => { - Assert.Same(model, v.ViewData.Model); - v.Writer.Write(expected); + var actual = Assert.IsType(v.ViewData.Model); + Assert.Same(expected, actual); }) - .Returns(Task.CompletedTask); + .Returns(Task.CompletedTask) + .Verifiable(); var viewEngine = new Mock(); viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) @@ -219,7 +221,7 @@ public async Task ProcessAsync_UsesPassedInModel() { Name = partialName, ViewContext = viewContext, - Model = model, + For = modelExpression, }; var tagHelperContext = GetTagHelperContext(); var output = GetTagHelperOutput(); @@ -228,15 +230,61 @@ public async Task ProcessAsync_UsesPassedInModel() await tagHelper.ProcessAsync(tagHelperContext, output); // Assert - var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); - Assert.Equal(expected, content); + view.Verify(); } [Fact] - public async Task ProcessAsync_UsesModelOnViewContextViewData_WhenModelPropertyIsNull() + public async Task ProcessAsync_SetsHtmlFieldPrefix_UsingModelExpression() + { + // Arrange + var expected = "order.items[0].Property"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var modelMetadataProvider = new TestModelMetadataProvider(); + var containerModel = new TestModel { Property = new PropertyModel() }; + var containerModelExplorer = modelMetadataProvider.GetModelExplorerForType( + typeof(TestModel), + containerModel); + var propertyModelExplorer = containerModelExplorer.GetExplorerForProperty(nameof(TestModel.Property)); + + var modelExpression = new ModelExpression("Property", propertyModelExplorer); + var viewContext = GetViewContext(); + viewContext.ViewData.TemplateInfo.HtmlFieldPrefix = "order.items[0]"; + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + Assert.Equal(expected, v.ViewData.TemplateInfo.HtmlFieldPrefix); + }) + .Returns(Task.CompletedTask) + .Verifiable(); + + 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, + For = modelExpression, + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act + await tagHelper.ProcessAsync(tagHelperContext, output); + + // Assert + view.Verify(); + Assert.Equal("order.items[0]", viewContext.ViewData.TemplateInfo.HtmlFieldPrefix); + } + + [Fact] + public async Task ProcessAsync_UsesModelOnViewContextViewData_WhenModelExpresionIsNull() { // Arrange - var expected = "Hello world!"; var bufferScope = new TestViewBufferScope(); var partialName = "_Partial"; var model = new object(); @@ -248,9 +296,9 @@ public async Task ProcessAsync_UsesModelOnViewContextViewData_WhenModelPropertyI .Callback((ViewContext v) => { Assert.Same(model, v.ViewData.Model); - v.Writer.Write(expected); }) - .Returns(Task.CompletedTask); + .Returns(Task.CompletedTask) + .Verifiable(); var viewEngine = new Mock(); viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) @@ -268,30 +316,64 @@ public async Task ProcessAsync_UsesModelOnViewContextViewData_WhenModelPropertyI await tagHelper.ProcessAsync(tagHelperContext, output); // Assert - var content = HtmlContentUtilities.HtmlContentToString(output.Content, new HtmlTestEncoder()); - Assert.Equal(expected, content); + view.Verify(); } [Fact] - public async Task ProcessAsync_DisposesViewInstance() + public async Task ProcessAsync_DoesNotModifyHtmlFieldPrefix_WhenModelExpressionIsNull() { // Arrange - var expected = "Hello world!"; + var expected = "original"; var bufferScope = new TestViewBufferScope(); var partialName = "_Partial"; var model = new object(); var viewContext = GetViewContext(); + viewContext.ViewData.Model = model; + viewContext.ViewData.TemplateInfo.HtmlFieldPrefix = expected; + + var view = new Mock(); + view.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + Assert.Equal(expected, v.ViewData.TemplateInfo.HtmlFieldPrefix); + }) + .Returns(Task.CompletedTask) + .Verifiable(); + + 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 + view.Verify(); + } + + [Fact] + public async Task ProcessAsync_DisposesViewInstance() + { + // Arrange + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + 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); + .Returns(Task.CompletedTask) + .Verifiable(); var viewEngine = new Mock(); viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) @@ -300,7 +382,6 @@ public async Task ProcessAsync_DisposesViewInstance() var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) { Name = partialName, - Model = model, ViewContext = viewContext, }; var tagHelperContext = GetTagHelperContext(); @@ -311,6 +392,7 @@ public async Task ProcessAsync_DisposesViewInstance() // Assert disposable.Verify(); + view.Verify(); } [Fact] @@ -378,5 +460,14 @@ private static TagHelperOutput GetTagHelperOutput() new TagHelperAttributeList(), (_, __) => Task.FromResult(new DefaultTagHelperContent())); } + + private class TestModel + { + public PropertyModel Property { get; set; } + } + + private class PropertyModel + { + } } } diff --git a/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs b/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs index 3402b1fbdf..c0f4560c1e 100644 --- a/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs +++ b/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs @@ -167,6 +167,22 @@ public IActionResult EditWarehouse() return View(warehouse); } + public IActionResult Warehouse() + { + var warehouse = new Warehouse + { + City = "City_1", + Employee = new Employee + { + Name = "EmployeeName_1", + OfficeNumber = "Number_1", + Address = "Address_1", + } + }; + + return View(warehouse); + } + public IActionResult Environment() { return View(); @@ -211,5 +227,7 @@ public IActionResult AttributesWithBooleanValues() { return View(); } + + public IActionResult PartialTagHelperWithoutModel() => View(); } } diff --git a/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/PartialTagHelperWithoutModel.cshtml b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/PartialTagHelperWithoutModel.cshtml new file mode 100644 index 0000000000..5074a5280f --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/PartialTagHelperWithoutModel.cshtml @@ -0,0 +1,2 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +PartialTagHelperWithoutModel: \ No newline at end of file diff --git a/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/ProductListUsingTagHelpers.cshtml b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/ProductListUsingTagHelpers.cshtml index 218277c8e4..17da2c943e 100644 --- a/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/ProductListUsingTagHelpers.cshtml +++ b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/ProductListUsingTagHelpers.cshtml @@ -1,5 +1,5 @@ @using HtmlGenerationWebSite.Models -@model IEnumerable +@model IList @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @@ -9,22 +9,17 @@
- @{ - var index = 0; - var fieldPrefix = ViewData.TemplateInfo.HtmlFieldPrefix; - foreach (var product in Model) + @for (var i = 0; i < Model.Count; i++) { - @* Update HtmlFieldPrefix so generated for, id and name attribute values are correct. *@ - ViewData.TemplateInfo.HtmlFieldPrefix = fieldPrefix + string.Format("[{0}]", index++);
- - + +
- - ViewData.TemplateInfo.HtmlFieldPrefix = fieldPrefix; + } + @* Print the HtmlFieldPrefix outside of the partial tag helper to ensure it hasn't been modified *@ +
HtmlFieldPrefix = @ViewData.TemplateInfo.HtmlFieldPrefix
- } diff --git a/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/Warehouse.cshtml b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/Warehouse.cshtml new file mode 100644 index 0000000000..64ca9c12af --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/Warehouse.cshtml @@ -0,0 +1,5 @@ +@model HtmlGenerationWebSite.Models.Warehouse +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + +

@Html.DisplayFor(m => m.City)

+ \ No newline at end of file diff --git a/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/_EmployeePartial.cshtml b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/_EmployeePartial.cshtml new file mode 100644 index 0000000000..d15ee345e3 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/_EmployeePartial.cshtml @@ -0,0 +1,15 @@ +@model HtmlGenerationWebSite.Models.Employee +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + +
+ + +
+
+ + +
+
+ + +
diff --git a/test/WebSites/HtmlGenerationWebSite/Views/Shared/_Partial.cshtml b/test/WebSites/HtmlGenerationWebSite/Views/Shared/_Partial.cshtml new file mode 100644 index 0000000000..fcdc0458e2 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Views/Shared/_Partial.cshtml @@ -0,0 +1 @@ +Hello from partial