From 15edd84d4037cbc43e01453ffc7e987fa8ad4bfb Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Tue, 15 Jan 2019 15:12:57 -0800 Subject: [PATCH] [MVC] Add MVC integration with Razor Components * Adds RenderComponentAsync extension methods to IHtmlHelper that allow for prerrendering of Razor components within MVC views. --- .../HtmlHelperComponentExtensions.cs | 83 +++++ ...crosoft.AspNetCore.Mvc.ViewFeatures.csproj | 1 + .../ComponentRenderingFunctionalTests.cs | 139 ++++++++ .../HtmlHelperComponentExtensionsTests.cs | 309 ++++++++++++++++++ .../Controllers/ComponentsController.cs | 74 +++++ .../RazorComponents/FetchData.razor | 53 +++ .../RazorComponents/Greetings.razor | 1 + .../Services/WeatherForecastService.cs | 45 +++ src/Mvc/test/WebSites/BasicWebSite/Startup.cs | 21 ++ .../Views/Components/Index.cshtml | 9 + 10 files changed, 735 insertions(+) create mode 100644 src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/HtmlHelperComponentExtensions.cs create mode 100644 src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs create mode 100644 src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/HtmlHelperComponentExtensionsTests.cs create mode 100644 src/Mvc/test/WebSites/BasicWebSite/Controllers/ComponentsController.cs create mode 100644 src/Mvc/test/WebSites/BasicWebSite/RazorComponents/FetchData.razor create mode 100644 src/Mvc/test/WebSites/BasicWebSite/RazorComponents/Greetings.razor create mode 100644 src/Mvc/test/WebSites/BasicWebSite/Services/WeatherForecastService.cs create mode 100644 src/Mvc/test/WebSites/BasicWebSite/Views/Components/Index.cshtml diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/HtmlHelperComponentExtensions.cs b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/HtmlHelperComponentExtensions.cs new file mode 100644 index 0000000000..dd45a46688 --- /dev/null +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/HtmlHelperComponentExtensions.cs @@ -0,0 +1,83 @@ +// 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.Collections.Generic; +using System.IO; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures +{ + /// + /// Extensions for rendering components. + /// + public static class HtmlHelperComponentExtensions + { + /// + /// Renders the . + /// + /// The . + /// The HTML produced by the rendered . + public static Task RenderComponentAsync(this IHtmlHelper htmlHelper) where TComponent : IComponent + { + if (htmlHelper == null) + { + throw new System.ArgumentNullException(nameof(htmlHelper)); + } + + return htmlHelper.RenderComponentAsync(null); + } + + /// + /// Renders the . + /// + /// The . + /// An containing the parameters to pass + /// to the component. + /// The HTML produced by the rendered . + public static async Task RenderComponentAsync( + this IHtmlHelper htmlHelper, + object parameters) where TComponent : IComponent + { + if (htmlHelper == null) + { + throw new System.ArgumentNullException(nameof(htmlHelper)); + } + + var serviceProvider = htmlHelper.ViewContext.HttpContext.RequestServices; + var encoder = serviceProvider.GetRequiredService(); + using (var htmlRenderer = new HtmlRenderer(serviceProvider, encoder.Encode)) + { + var result = await htmlRenderer.RenderComponentAsync( + parameters == null ? + ParameterCollection.Empty : + ParameterCollection.FromDictionary(HtmlHelper.ObjectToDictionary(parameters))); + + return new ComponentHtmlContent(result); + } + } + + private class ComponentHtmlContent : IHtmlContent + { + private readonly IEnumerable _componentResult; + + public ComponentHtmlContent(IEnumerable componentResult) + { + _componentResult = componentResult; + } + + public void WriteTo(TextWriter writer, HtmlEncoder encoder) + { + foreach (var element in _componentResult) + { + writer.Write(element); + } + } + } + } +} diff --git a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj index 2bbb0d2944..5dbf2094c3 100644 --- a/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj +++ b/src/Mvc/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Microsoft.AspNetCore.Mvc.ViewFeatures.csproj @@ -21,6 +21,7 @@ Microsoft.AspNetCore.Mvc.ViewComponent + diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs new file mode 100644 index 0000000000..d6f95edc2e --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs @@ -0,0 +1,139 @@ +// 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.Net; +using System.Net.Http; +using System.Threading.Tasks; +using AngleSharp.Parser.Html; +using BasicWebSite.Services; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests +{ + public class ComponentRenderingFunctionalTests : IClassFixture> + { + public ComponentRenderingFunctionalTests(MvcTestFixture fixture) + { + Client = Client ?? CreateClient(fixture); + } + + public HttpClient Client { get; } + + [Fact] + public async Task Renders_BasicComponent() + { + // Arrange & Act + var response = await Client.GetAsync("http://localhost/components"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + + AssertComponent("\n

Hello world!

\n", "Greetings", content); + } + + [Fact] + public async Task Renders_AsyncComponent() + { + // Arrange & Act + var expectedHtml = @" +

Weather forecast

+ +

This component demonstrates fetching data from the server.

+ +

Weather data for 01/15/2019

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateTemp. (C)Temp. (F)Summary
06/05/2018133Freezing
07/05/20181457Bracing
08/05/2018-139Freezing
09/05/2018-164Balmy
10/05/2018229Chilly
+ +"; + + var response = await Client.GetAsync("http://localhost/components"); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + + AssertComponent(expectedHtml, "FetchData", content); + } + + private void AssertComponent(string expectedConent, string divId, string responseContent) + { + var parser = new HtmlParser(); + var htmlDocument = parser.Parse(responseContent); + var div = htmlDocument.Body.QuerySelector($"#{divId}"); + Assert.Equal( + expectedConent.Replace("\r\n","\n"), + div.InnerHtml.Replace("\r\n","\n")); + } + + // A simple delegating handler used in setting up test services so that we can configure + // services that talk back to the TestServer using HttpClient. + private class LoopHttpHandler : DelegatingHandler + { + } + + private HttpClient CreateClient(MvcTestFixture fixture) + { + var loopHandler = new LoopHttpHandler(); + + var client = fixture + .WithWebHostBuilder(builder => builder.ConfigureServices(ConfigureTestWeatherForecastService)) + .CreateClient(); + + // We configure the inner handler with a handler to this TestServer instance so that calls to the + // server can get routed properly. + loopHandler.InnerHandler = fixture.Server.CreateHandler(); + + void ConfigureTestWeatherForecastService(IServiceCollection services) => + // We configure the test service here with an HttpClient that uses this loopback handler to talk + // to this TestServer instance. + services.AddSingleton(new WeatherForecastService(new HttpClient(loopHandler) + { + BaseAddress = fixture.ClientOptions.BaseAddress + })); + + return client; + } + } +} diff --git a/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/HtmlHelperComponentExtensionsTests.cs b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/HtmlHelperComponentExtensionsTests.cs new file mode 100644 index 0000000000..b9439dbae1 --- /dev/null +++ b/src/Mvc/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/HtmlHelperComponentExtensionsTests.cs @@ -0,0 +1,309 @@ +// 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.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Test +{ + public class HtmlHelperComponentExtensionsTests + { + [Fact] + public async Task CanRender_ParameterlessComponent() + { + // Arrange + var helper = CreateHelper(); + var writer = new StringWriter(); + + // Act + var result = await helper.RenderComponentAsync(); + result.WriteTo(writer, HtmlEncoder.Default); + var content = writer.ToString(); + + // Assert + Assert.Equal("

Hello world!

", content); + } + + [Fact] + public async Task CanRender_ComponentWithParametersObject() + { + // Arrange + var helper = CreateHelper(); + var writer = new StringWriter(); + + // Act + var result = await helper.RenderComponentAsync(new + { + Name = "Steve" + }); + result.WriteTo(writer, HtmlEncoder.Default); + var content = writer.ToString(); + + // Assert + Assert.Equal("

Hello Steve!

", content); + } + + [Fact] + public async Task CanRender_AsyncComponent() + { + // Arrange + var helper = CreateHelper(); + var writer = new StringWriter(); + var expectedContent = @" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DateSummaryFC
06/05/2018Freezing3333
07/05/2018Bracing5757
08/05/2018Freezing99
09/05/2018Balmy44
10/05/2018Chilly2929
"; + + // Act + var result = await helper.RenderComponentAsync(); + result.WriteTo(writer, HtmlEncoder.Default); + var content = writer.ToString(); + + // Assert + Assert.Equal(expectedContent.Replace("\r\n","\n"), content); + } + + private static IHtmlHelper CreateHelper(Action configureServices = null) + { + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(HtmlEncoder.Default); + configureServices?.Invoke(serviceCollection); + + var helper = new Mock(); + helper.Setup(h => h.ViewContext) + .Returns(new ViewContext() + { + HttpContext = new DefaultHttpContext() + { + RequestServices = serviceCollection.BuildServiceProvider() + } + }); + return helper.Object; + } + + private class TestComponent : IComponent + { + private RenderHandle _renderHandle; + + public void Configure(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + } + + public Task SetParametersAsync(ParameterCollection parameters) + { + _renderHandle.Render(builder => + { + var s = 0; + builder.OpenElement(s++, "h1"); + builder.AddContent(s++, "Hello world!"); + builder.CloseElement(); + }); + return Task.CompletedTask; + } + } + + private class GreetingComponent : ComponentBase + { + [Parameter] public string Name { get; set; } + + protected override void OnParametersSet() + { + base.OnParametersSet(); + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + var s = 0; + base.BuildRenderTree(builder); + builder.OpenElement(s++, "p"); + builder.AddContent(s++, $"Hello {Name}!"); + builder.CloseElement(); + } + } + + private class AsyncComponent : ComponentBase + { + private static WeatherRow[] _weatherData = new[] + { + new WeatherRow + { + DateFormatted = "06/05/2018", + TemperatureC = 1, + Summary = "Freezing", + TemperatureF = 33 + }, + new WeatherRow + { + DateFormatted = "07/05/2018", + TemperatureC = 14, + Summary = "Bracing", + TemperatureF = 57 + }, + new WeatherRow + { + DateFormatted = "08/05/2018", + TemperatureC = -13, + Summary = "Freezing", + TemperatureF = 9 + }, + new WeatherRow + { + DateFormatted = "09/05/2018", + TemperatureC = -16, + Summary = "Balmy", + TemperatureF = 4 + }, + new WeatherRow + { + DateFormatted = "10/05/2018", + TemperatureC = 2, + Summary = "Chilly", + TemperatureF = 29 + } + }; + + public class WeatherRow + { + public string DateFormatted { get; set; } + public int TemperatureC { get; set; } + public string Summary { get; set; } + public int TemperatureF { get; set; } + } + + public WeatherRow[] RowsToDisplay { get; set; } + + protected override async Task OnParametersSetAsync() + { + // Simulate an async workflow. + await Task.Yield(); + RowsToDisplay = _weatherData; + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + base.BuildRenderTree(builder); + var s = 0; + builder.OpenElement(s++, "table"); + builder.AddMarkupContent(s++, "\n"); + builder.OpenElement(s++, "thead"); + builder.AddMarkupContent(s++, "\n"); + builder.OpenElement(s++, "tr"); + builder.AddMarkupContent(s++, "\n"); + + builder.OpenElement(s++, "th"); + builder.AddContent(s++, "Date"); + builder.CloseElement(); + builder.AddMarkupContent(s++, "\n"); + + builder.OpenElement(s++, "th"); + builder.AddContent(s++, "Summary"); + builder.CloseElement(); + builder.AddMarkupContent(s++, "\n"); + + builder.OpenElement(s++, "th"); + builder.AddContent(s++, "F"); + builder.CloseElement(); + builder.AddMarkupContent(s++, "\n"); + + builder.OpenElement(s++, "th"); + builder.AddContent(s++, "C"); + builder.CloseElement(); + builder.AddMarkupContent(s++, "\n"); + + builder.CloseElement(); + builder.AddMarkupContent(s++, "\n"); + builder.CloseElement(); + builder.AddMarkupContent(s++, "\n"); + builder.OpenElement(s++, "tbody"); + builder.AddMarkupContent(s++, "\n"); + if (RowsToDisplay != null) + { + var s2 = s; + foreach (var element in RowsToDisplay) + { + s = s2; + builder.OpenElement(s++, "tr"); + builder.AddMarkupContent(s++, "\n"); + + builder.OpenElement(s++, "td"); + builder.AddContent(s++, element.DateFormatted); + builder.CloseElement(); + builder.AddMarkupContent(s++, "\n"); + + builder.OpenElement(s++, "td"); + builder.AddContent(s++, element.Summary); + builder.CloseElement(); + builder.AddMarkupContent(s++, "\n"); + + builder.OpenElement(s++, "td"); + builder.AddContent(s++, element.TemperatureF); + builder.CloseElement(); + builder.AddMarkupContent(s++, "\n"); + + builder.OpenElement(s++, "td"); + builder.AddContent(s++, element.TemperatureF); + builder.CloseElement(); + builder.AddMarkupContent(s++, "\n"); + + builder.CloseElement(); + builder.AddMarkupContent(s++, "\n"); + } + } + + builder.CloseElement(); + builder.AddMarkupContent(s++, "\n"); + + builder.CloseElement(); + } + } + } +} diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/ComponentsController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/ComponentsController.cs new file mode 100644 index 0000000000..99ba2690ee --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/ComponentsController.cs @@ -0,0 +1,74 @@ +// 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.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +namespace BasicWebSite.Controllers +{ + public class ComponentsController : Controller + { + private static WeatherRow[] _weatherData = new[] + { + new WeatherRow + { + DateFormatted = "06/05/2018", + TemperatureC = 1, + Summary = "Freezing", + TemperatureF = 33 + }, + new WeatherRow + { + DateFormatted = "07/05/2018", + TemperatureC = 14, + Summary = "Bracing", + TemperatureF = 57 + }, + new WeatherRow + { + DateFormatted = "08/05/2018", + TemperatureC = -13, + Summary = "Freezing", + TemperatureF = 9 + }, + new WeatherRow + { + DateFormatted = "09/05/2018", + TemperatureC = -16, + Summary = "Balmy", + TemperatureF = 4 + }, + new WeatherRow + { + DateFormatted = "10/05/2018", + TemperatureC = 2, + Summary = "Chilly", + TemperatureF = 29 + } + }; + + [HttpGet("/components")] + public IActionResult Index() + { + return View(); + } + + [HttpGet("/WeatherData")] + [Produces("application/json")] + public IActionResult WeatherData() + { + return Ok(_weatherData); + } + + private class WeatherRow + { + public string DateFormatted { get; internal set; } + public int TemperatureC { get; internal set; } + public string Summary { get; internal set; } + public int TemperatureF { get; internal set; } + } + } +} diff --git a/src/Mvc/test/WebSites/BasicWebSite/RazorComponents/FetchData.razor b/src/Mvc/test/WebSites/BasicWebSite/RazorComponents/FetchData.razor new file mode 100644 index 0000000000..79f6d6937c --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/RazorComponents/FetchData.razor @@ -0,0 +1,53 @@ +@using BasicWebSite.Services +@inject WeatherForecastService ForecastService + +

Weather forecast

+ +

This component demonstrates fetching data from the server.

+ +@if (forecasts == null) +{ +

Loading...

+} +else +{ +

Weather data for @(StartDate.ToString("MM/dd/yyyy"))

+ + + + + + + + + + + @foreach (var forecast in forecasts) + { + + + + + + + } + +
DateTemp. (C)Temp. (F)Summary
@forecast.DateFormatted@forecast.TemperatureC@forecast.TemperatureF@forecast.Summary
+} + +@functions { + [Parameter] DateTime StartDate { get; set; } + + WeatherForecast[] forecasts; + + protected override async Task OnParametersSetAsync() + { + // If no value was given in the URL for StartDate, apply a default + if (StartDate == default) + { + StartDate = DateTime.Now; + } + + forecasts = await ForecastService.GetForecastAsync(StartDate); + } +} diff --git a/src/Mvc/test/WebSites/BasicWebSite/RazorComponents/Greetings.razor b/src/Mvc/test/WebSites/BasicWebSite/RazorComponents/Greetings.razor new file mode 100644 index 0000000000..bd67e43af9 --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/RazorComponents/Greetings.razor @@ -0,0 +1 @@ +

Hello world!

\ No newline at end of file diff --git a/src/Mvc/test/WebSites/BasicWebSite/Services/WeatherForecastService.cs b/src/Mvc/test/WebSites/BasicWebSite/Services/WeatherForecastService.cs new file mode 100644 index 0000000000..5491c579ec --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/Services/WeatherForecastService.cs @@ -0,0 +1,45 @@ +// 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.Net.Http; +using System.Threading.Tasks; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace BasicWebSite.Services +{ + public class WeatherForecastService + { + private readonly HttpClient _httpClient; + + public WeatherForecastService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task GetForecastAsync(DateTime startDate) + { + var result = await _httpClient.GetAsync("/WeatherData"); + result.EnsureSuccessStatusCode(); + var dataString = await result.Content.ReadAsStringAsync(); + var weatherData = JsonConvert.DeserializeObject( + dataString, new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() + } + }); + return weatherData; + } + } + + public class WeatherForecast + { + public string DateFormatted { get; set; } + public int TemperatureC { get; set; } + public string Summary { get; set; } + public int TemperatureF { get; set; } + } +} \ No newline at end of file diff --git a/src/Mvc/test/WebSites/BasicWebSite/Startup.cs b/src/Mvc/test/WebSites/BasicWebSite/Startup.cs index 8147a6b774..978ce38fe7 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Startup.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Startup.cs @@ -1,13 +1,18 @@ // 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.Net.Http; +using BasicWebSite.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace BasicWebSite { @@ -48,6 +53,22 @@ namespace BasicWebSite services.AddTransient(); services.AddScoped(); services.AddSingleton(); + services.TryAddSingleton(CreateWeatherForecastService); + } + + // For manual debug only (running this test site with F5) + // This needs to be changed to match the site host + private WeatherForecastService CreateWeatherForecastService(IServiceProvider serviceProvider) + { + var contextAccessor = serviceProvider.GetRequiredService(); + var httpContext = contextAccessor.HttpContext; + if (httpContext == null) + { + throw new InvalidOperationException("Needs a request context!"); + } + var client = new HttpClient(); + client.BaseAddress = new Uri($"{httpContext.Request.Scheme}://{httpContext.Request.Host}"); + return new WeatherForecastService(client); } public void Configure(IApplicationBuilder app) diff --git a/src/Mvc/test/WebSites/BasicWebSite/Views/Components/Index.cshtml b/src/Mvc/test/WebSites/BasicWebSite/Views/Components/Index.cshtml new file mode 100644 index 0000000000..6bff9af76f --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/Views/Components/Index.cshtml @@ -0,0 +1,9 @@ +@using BasicWebSite.RazorComponents; +

Razor components

+
+ @(await Html.RenderComponentAsync()) +
+ +
+ @(await Html.RenderComponentAsync(new { StartDate = new DateTime(2019, 01, 15) })) +
\ No newline at end of file