From dc2ae93c3f37ff7f44c2d09f68065b19db6a3245 Mon Sep 17 00:00:00 2001 From: Alexej Timonin Date: Sun, 1 Jul 2018 16:29:54 -0700 Subject: [PATCH] Add fallback attribute to partial tag helper. Addresses #7515 --- .../PartialTagHelper.cs | 75 ++++--- .../Properties/Resources.Designer.cs | 14 ++ .../Resources.resx | 3 + .../HtmlGenerationTest.cs | 28 +++ .../PartialTagHelperTest.cs | 192 ++++++++++++++++++ .../Customer/Pages/PartialWithFallback.cshtml | 3 + .../Customer/Pages/PartialWithOptional.cshtml | 3 + .../Areas/Customer/Pages/_Fallback.cshtml | 1 + .../Areas/Customer/Pages/_ViewImports.cshtml | 1 + 9 files changed, 295 insertions(+), 25 deletions(-) create mode 100644 test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithFallback.cshtml create mode 100644 test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithOptional.cshtml create mode 100644 test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_Fallback.cshtml create mode 100644 test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_ViewImports.cshtml diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs index fef6b7c0e8..617fa5d3a2 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/PartialTagHelper.cs @@ -22,6 +22,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers { private const string ForAttributeName = "for"; private const string ModelAttributeName = "model"; + private const string FallbackAttributeName = "fallback-name"; private const string OptionalAttributeName = "optional"; private object _model; private bool _hasModel; @@ -82,6 +83,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers [HtmlAttributeName(OptionalAttributeName)] public bool Optional { get; set; } + /// + /// View to lookup if the view specified by cannot be located. + /// + [HtmlAttributeName(FallbackAttributeName)] + public string FallbackName { get; set; } + /// /// A to pass into the partial view. /// @@ -104,21 +111,46 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers throw new ArgumentNullException(nameof(context)); } - var viewEngineResult = FindView(); - - if (viewEngineResult.Success) - { - var model = ResolveModel(); - var viewBuffer = new ViewBuffer(_viewBufferScope, Name, ViewBuffer.PartialViewPageSize); - using (var writer = new ViewBufferTextWriter(viewBuffer, Encoding.UTF8)) - { - await RenderPartialViewAsync(writer, model, viewEngineResult.View); - output.Content.SetHtmlContent(viewBuffer); - } - } - // Reset the TagName. We don't want `partial` to render. output.TagName = null; + + var result = FindView(Name); + var viewSearchedLocations = result.SearchedLocations; + var fallBackViewSearchedLocations = Enumerable.Empty(); + + if (!result.Success && !string.IsNullOrEmpty(FallbackName)) + { + result = FindView(FallbackName); + fallBackViewSearchedLocations = result.SearchedLocations; + } + + if (!result.Success) + { + if (Optional) + { + // Could not find the view or fallback view, but the partial is marked as optional. + return; + } + + var locations = Environment.NewLine + string.Join(Environment.NewLine, viewSearchedLocations); + var errorMessage = Resources.FormatViewEngine_PartialViewNotFound(Name, locations); + + if (!string.IsNullOrEmpty(FallbackName)) + { + locations = Environment.NewLine + string.Join(Environment.NewLine, result.SearchedLocations); + errorMessage += Environment.NewLine + Resources.FormatViewEngine_FallbackViewNotFound(FallbackName, locations); + } + + throw new InvalidOperationException(errorMessage); + } + + var model = ResolveModel(); + var viewBuffer = new ViewBuffer(_viewBufferScope, result.ViewName, ViewBuffer.PartialViewPageSize); + using (var writer = new ViewBufferTextWriter(viewBuffer, Encoding.UTF8)) + { + await RenderPartialViewAsync(writer, model, result.View); + output.Content.SetHtmlContent(viewBuffer); + } } // Internal for testing @@ -152,26 +184,19 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers return ViewContext.ViewData.Model; } - private ViewEngineResult FindView() + private ViewEngineResult FindView(string partialName) { - var viewEngineResult = _viewEngine.GetView(ViewContext.ExecutingFilePath, Name, isMainPage: false); + var viewEngineResult = _viewEngine.GetView(ViewContext.ExecutingFilePath, partialName, isMainPage: false); var getViewLocations = viewEngineResult.SearchedLocations; if (!viewEngineResult.Success) { - viewEngineResult = _viewEngine.FindView(ViewContext, Name, isMainPage: false); + viewEngineResult = _viewEngine.FindView(ViewContext, partialName, isMainPage: false); } - if (!viewEngineResult.Success && !Optional) + 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)); + return ViewEngineResult.NotFound(partialName, searchedLocations); } return viewEngineResult; diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs index 93ac7fa60e..f5b5d8c690 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Properties/Resources.Designer.cs @@ -206,6 +206,20 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers internal static string FormatPartialTagHelper_InvalidModelAttributes(object p0, object p1, object p2) => string.Format(CultureInfo.CurrentCulture, GetString("PartialTagHelper_InvalidModelAttributes"), p0, p1, p2); + /// + /// The fallback partial view '{0}' was not found. The following locations were searched:{1} + /// + internal static string ViewEngine_FallbackViewNotFound + { + get => GetString("ViewEngine_FallbackViewNotFound"); + } + + /// + /// The fallback partial view '{0}' was not found. The following locations were searched:{1} + /// + internal static string FormatViewEngine_FallbackViewNotFound(object p0, object p1) + => string.Format(CultureInfo.CurrentCulture, GetString("ViewEngine_FallbackViewNotFound"), 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 1d6166e955..96c56dff7f 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Resources.resx @@ -159,4 +159,7 @@ Cannot use '{0}' with both '{1}' and '{2}' attributes. + + The fallback 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/HtmlGenerationTest.cs b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs index e0ce243842..32cb029485 100644 --- a/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.FunctionalTests/HtmlGenerationTest.cs @@ -585,6 +585,34 @@ Products: Music Systems, Televisions (3)"; Assert.Empty(banner.TextContent); } + [Fact] + public async Task PartialTagHelper_AllowsUsingFallback() + { + // Arrange + var url = "/Customer/PartialWithFallback"; + + // Act + var document = await Client.GetHtmlDocumentAsync(url); + + // Assert + var content = document.RequiredQuerySelector("#content"); + Assert.Equal("Hello from fallback", content.TextContent); + } + + [Fact] + public async Task PartialTagHelper_AllowsUsingOptional() + { + // Arrange + var url = "/Customer/PartialWithOptional"; + + // Act + var document = await Client.GetHtmlDocumentAsync(url); + + // Assert + var content = document.RequiredQuerySelector("#content"); + Assert.Empty(content.TextContent); + } + 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 21edd86f7d..489c78bd05 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/PartialTagHelperTest.cs @@ -648,6 +648,198 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers Assert.Empty(content); } + [Fact] + public async Task ProcessAsync_RendersMainPartial_If_FallbackIsSet_AndMainPartialIsFound() + { + // Arrange + var expected = "Hello from partial!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var fallbackName = "_Fallback"; + 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 fallbackView = new Mock(); + fallbackView.Setup(v => v.RenderAsync(It.IsAny())) + .Callback((ViewContext v) => + { + v.Writer.Write("Hello from fallback partial!"); + }) + .Returns(Task.CompletedTask); + + var viewEngine = new Mock(); + viewEngine.Setup(v => v.GetView(It.IsAny(), partialName, false)) + .Returns(ViewEngineResult.Found(partialName, view.Object)); + viewEngine.Setup(v => v.GetView(It.IsAny(), fallbackName, false)) + .Returns(ViewEngineResult.Found(fallbackName, fallbackView.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + FallbackName = fallbackName + }; + 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_IfHasFallback_Throws_When_MainPartialAndFallback_AreNotFound() + { + // Arrange + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var fallbackName = "_Fallback"; + var expected = string.Join( + Environment.NewLine, + $"The partial view '{partialName}' was not found. The following locations were searched:", + "PartialNotFound1", + "PartialNotFound2", + "PartialNotFound3", + "PartialNotFound4", + $"The fallback partial view '{fallbackName}' was not found. The following locations were searched:", + "FallbackNotFound1", + "FallbackNotFound2", + "FallbackNotFound3", + "FallbackNotFound4"); + 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[] { "PartialNotFound1", "PartialNotFound2" })); + + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { $"PartialNotFound3", $"PartialNotFound4" })); + + viewEngine.Setup(v => v.GetView(It.IsAny(), fallbackName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { "FallbackNotFound1", "FallbackNotFound2" })); + + viewEngine.Setup(v => v.FindView(viewContext, fallbackName, false)) + .Returns(ViewEngineResult.NotFound(partialName, new[] { $"FallbackNotFound3", $"FallbackNotFound4" })); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + ViewData = viewData, + FallbackName = fallbackName + }; + var tagHelperContext = GetTagHelperContext(); + var output = GetTagHelperOutput(); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => tagHelper.ProcessAsync(tagHelperContext, output)); + Assert.Equal(expected, exception.Message); + } + + [Fact] + public async Task ProcessAsync_RendersFallbackView_If_MainIsNotFound_AndGetViewReturnsView() + { + // Arrange + var expected = "Hello from fallback!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var fallbackName = "_Fallback"; + 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, Array.Empty())); + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, Array.Empty())); + viewEngine.Setup(v => v.GetView(It.IsAny(), fallbackName, false)) + .Returns(ViewEngineResult.Found(fallbackName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + FallbackName = fallbackName + }; + 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_RendersFallbackView_If_MainIsNotFound_AndFindViewReturnsView() + { + // Arrange + var expected = "Hello from fallback!"; + var bufferScope = new TestViewBufferScope(); + var partialName = "_Partial"; + var fallbackName = "_Fallback"; + 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, Array.Empty())); + viewEngine.Setup(v => v.FindView(viewContext, partialName, false)) + .Returns(ViewEngineResult.NotFound(partialName, Array.Empty())); + viewEngine.Setup(v => v.GetView(It.IsAny(), fallbackName, false)) + .Returns(ViewEngineResult.NotFound(fallbackName, Array.Empty())); + viewEngine.Setup(v => v.FindView(viewContext, fallbackName, false)) + .Returns(ViewEngineResult.Found(fallbackName, view.Object)); + + var tagHelper = new PartialTagHelper(viewEngine.Object, bufferScope) + { + Name = partialName, + ViewContext = viewContext, + FallbackName = fallbackName + }; + 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); + } + private static ViewContext GetViewContext() { return new ViewContext( diff --git a/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithFallback.cshtml b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithFallback.cshtml new file mode 100644 index 0000000000..fc6b995ab1 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithFallback.cshtml @@ -0,0 +1,3 @@ +@page + + \ No newline at end of file diff --git a/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithOptional.cshtml b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithOptional.cshtml new file mode 100644 index 0000000000..c3c8ecd2cb --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/PartialWithOptional.cshtml @@ -0,0 +1,3 @@ +@page + +
\ No newline at end of file diff --git a/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_Fallback.cshtml b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_Fallback.cshtml new file mode 100644 index 0000000000..dee9334704 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_Fallback.cshtml @@ -0,0 +1 @@ +Hello from fallback \ No newline at end of file diff --git a/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_ViewImports.cshtml b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_ViewImports.cshtml new file mode 100644 index 0000000000..a757b413b9 --- /dev/null +++ b/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Pages/_ViewImports.cshtml @@ -0,0 +1 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers