diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs index dc4be24ae1..b8451f663e 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs @@ -22,6 +22,10 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { private const string ForAttributeName = "for"; private const string ModelAttributeName = "model"; + private object _model; + private bool _hasModel; + private bool _hasFor; + private ModelExpression _for; private readonly ICompositeViewEngine _viewEngine; private readonly IViewBufferScope _viewBufferScope; @@ -43,13 +47,29 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers /// An expression to be evaluated against the current model. Cannot be used together with . /// [HtmlAttributeName(ForAttributeName)] - public ModelExpression For { get; set; } + public ModelExpression For + { + get => _for; + set + { + _for = value ?? throw new ArgumentNullException(nameof(value)); + _hasFor = true; + } + } /// /// The model to pass into the partial view. Cannot be used together with . /// [HtmlAttributeName(ModelAttributeName)] - public object Model { get; set; } + public object Model + { + get => _model; + set + { + _model = value; + _hasModel = true; + } + } /// /// A to pass into the partial view. @@ -88,7 +108,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers // Internal for testing internal object ResolveModel() { - if (Model != null & For != null) + // 1. Disallow specifying values for both Model and For + // 2. If a Model was assigned, use it even if it's null. + // 3. For cannot have a null value. Use it if it was assigned to. + // 4. Fall back to using the Model property on ViewContext.ViewData if none of the above conditions are met. + + if (_hasFor && _hasModel) { throw new InvalidOperationException( Resources.FormatPartialTagHelper_InvalidModelAttributes( @@ -97,17 +122,17 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers ModelAttributeName)); } - if (Model != null) + if (_hasModel) { return Model; } - if (For != null) + if (_hasFor) { return For.Model; } - // Model and For are null, fallback to the ViewContext's ViewData model. + // A value for Model or For was not specified, fallback to the ViewContext's ViewData model. return ViewContext.ViewData.Model; } diff --git a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs index 9e3521d851..f865f1bcd4 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs @@ -9,6 +9,8 @@ using System.Net.Http.Headers; using System.Reflection; using System.Text; using System.Threading.Tasks; +using AngleSharp.Dom; +using AngleSharp.Dom.Html; using Xunit; namespace Microsoft.AspNetCore.Mvc.FunctionalTests @@ -554,6 +556,46 @@ Products: Music Systems, Televisions (3)"; Assert.Contains("Hello, Private World!", response); } + [Fact] + public async Task PartialTagHelper_AllowsPassingModelValue() + { + // Arrange + var url = "/HtmlGeneration_Home/StatusMessage"; + + // Act + var document = await Client.GetHtmlDocumentAsync(url); + + // Assert + var banner = QuerySelector(document, ".banner"); + Assert.Equal("Some status message", banner.TextContent); + } + + [Fact] + public async Task PartialTagHelper_AllowsPassingNullModelValue() + { + // Regression test for https://github.com/aspnet/Mvc/issues/7667. + // Arrange + var url = "/HtmlGeneration_Home/NullStatusMessage"; + + // Act + var document = await Client.GetHtmlDocumentAsync(url); + + // Assert + var banner = QuerySelector(document, ".banner"); + Assert.Empty(banner.TextContent); + } + + private static IElement QuerySelector(IHtmlDocument document, string selector) + { + var element = document.QuerySelector(selector); + if (element == null) + { + throw new ArgumentException($"Document does not contain element that matches the selector {selector}: " + Environment.NewLine + document.DocumentElement.OuterHtml); + } + + return element; + } + private static HttpRequestMessage RequestWithLocale(string url, string locale) { var request = new HttpRequestMessage(HttpMethod.Get, url); diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs index 8fa8c4f502..d1f0e16ee1 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs @@ -40,6 +40,28 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Assert.Same(expectedModel, model); } + [Fact] + public void ResolveModel_ReturnsModelWhenNullValueIsProvided() + { + // Regression test for https://github.com/aspnet/Mvc/issues/7667. + // Arrange + var viewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()) + { + Model = new object(), + }; + var tagHelper = new PartialTagHelper(Mock.Of(), Mock.Of()) + { + Model = null, + ViewData = viewData, + }; + + // Act + var model = tagHelper.ResolveModel(); + + // Assert + Assert.Null(model); + } + [Fact] public void ResolveModel_ReturnsForModelWhenProvided() { @@ -67,7 +89,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers } [Fact] - public void ResolveModel_ReturnsViewContextsViewDataModelWhenModelAndForAreNull() + public void ResolveModel_ReturnsViewContextsViewDataModelWhenModelAndForAreNotSet() { // Arrange var expectedModel = new object(); @@ -107,6 +129,28 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Assert.Equal(expectedMessage, exception.Message); } + [Fact] + public void ResolveModel_ThrowsWhenNullModelAndForProvided() + { + // Arrange + var modelMetadataProvider = new TestModelMetadataProvider(); + var containerModel = new TestModel(); + var containerModelExplorer = modelMetadataProvider.GetModelExplorerForType( + typeof(TestModel), + containerModel); + var propertyModelExplorer = containerModelExplorer.GetExplorerForProperty(nameof(TestModel.Property)); + var tagHelper = new PartialTagHelper(Mock.Of(), Mock.Of()) + { + Model = null, + For = new ModelExpression("Property", propertyModelExplorer), + }; + var expectedMessage = Resources.FormatPartialTagHelper_InvalidModelAttributes(typeof(PartialTagHelper).FullName, "for", "model"); + + // Act & Assert + var exception = Assert.Throws(() => tagHelper.ResolveModel()); + Assert.Equal(expectedMessage, exception.Message); + } + [Fact] public async Task ProcessAsync_RendersPartialView_IfGetViewReturnsView() { diff --git a/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs b/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs index d41eb9100e..27dd20482f 100644 --- a/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs +++ b/test/WebSites/HtmlGenerationWebSite/Controllers/HtmlGeneration_HomeController.cs @@ -239,5 +239,9 @@ namespace HtmlGenerationWebSite.Controllers } public IActionResult PartialTagHelperWithoutModel() => View(); + + public IActionResult StatusMessage() => View(new StatusMessageModel { Message = "Some status message"}); + + public IActionResult NullStatusMessage() => View("StatusMessage", new StatusMessageModel()); } } diff --git a/test/WebSites/HtmlGenerationWebSite/Models/StatusMessageModel.cs b/test/WebSites/HtmlGenerationWebSite/Models/StatusMessageModel.cs new file mode 100644 index 0000000000..62161e05cc --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Models/StatusMessageModel.cs @@ -0,0 +1,10 @@ +// 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. + +namespace HtmlGenerationWebSite.Models +{ + public class StatusMessageModel + { + public string Message { get; set; } + } +} diff --git a/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/StatusMessage.cshtml b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/StatusMessage.cshtml new file mode 100644 index 0000000000..68576bedd7 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/StatusMessage.cshtml @@ -0,0 +1,5 @@ +@using HtmlGenerationWebSite.Models +@model StatusMessageModel +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + + diff --git a/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/_StatusMessagePartial.cshtml b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/_StatusMessagePartial.cshtml new file mode 100644 index 0000000000..a7ff9eec3f --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Views/HtmlGeneration_Home/_StatusMessagePartial.cshtml @@ -0,0 +1,2 @@ +@model string +