From 5ae7103ec47c2b4f54c1b9a61fb973c1dd2f45ab Mon Sep 17 00:00:00 2001 From: Howard Richards Date: Sun, 18 Apr 2021 09:23:23 +0100 Subject: [PATCH] Added fluent ComponentRenderer type - fixes #12 --- .../ComponentRenderer_Tests.cs | 211 ++++++++++++++++++ BlazorTemplater/BlazorTemplater.csproj | 1 + BlazorTemplater/ComponentRenderer.cs | 127 +++++++++++ BlazorTemplater/ParameterViewBuilder.cs | 75 +++++++ BlazorTemplater/Templater.cs | 25 ++- Docs/Usage.md | 49 +++- README.md | 54 ++++- 7 files changed, 530 insertions(+), 12 deletions(-) create mode 100644 BlazorTemplater.Tests/ComponentRenderer_Tests.cs create mode 100644 BlazorTemplater/ComponentRenderer.cs create mode 100644 BlazorTemplater/ParameterViewBuilder.cs diff --git a/BlazorTemplater.Tests/ComponentRenderer_Tests.cs b/BlazorTemplater.Tests/ComponentRenderer_Tests.cs new file mode 100644 index 0000000..de10678 --- /dev/null +++ b/BlazorTemplater.Tests/ComponentRenderer_Tests.cs @@ -0,0 +1,211 @@ +using BlazorTemplater.Library; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace BlazorTemplater.Tests +{ + /// + /// + /// + [TestClass] + public class ComponentRenderer_Tests + { + [TestMethod] + public void Ctor_Test() + { + var builder = new ComponentRenderer(); + + Assert.IsNotNull(builder); + } + + #region Simple render + + /// + /// Render a component (no service injection or parameters) + /// + [TestMethod] + public void Simple_Test() + { + const string expected = @"Jan 1st is 2021-01-01"; + var actual = new ComponentRenderer() + .Render(); + + Console.WriteLine(actual); + Assert.AreEqual(expected, actual); + } + + #endregion Simple render + + #region Parameters + + /// + /// Test a component with a parameter + /// + [TestMethod] + public void ComponentBuilder_Parameters_Test() + { + // expected output + const string expected = "

Steve Sanderson is awesome!

"; + + var model = new TestModel() + { + Name = "Steve Sanderson", + Description = "is awesome" + }; + + var html = new ComponentRenderer() + .Set(c => c.Model, model) + .Render(); + + // trim leading space and trailing CRLF from output + var actual = html.Trim(); + + Console.WriteLine(actual); + Assert.AreEqual(expected, actual); + } + + /// + /// Test a component with a parameter + /// + [TestMethod] + public void ComponentBuilder_Parameters_TestHtmlEncoding() + { + // expected output + const string expected = "

Safia & Pranav are awesome too!

"; + + var templater = new Templater(); + var model = new TestModel() + { + Name = "Safia & Pranav", // the text here is HTML encoded + Description = "are awesome too" + }; + var html = new ComponentRenderer() + .Set(c => c.Model, model) + .Render(); + + // trim leading space and trailing CRLF from output + var actual = html.Trim(); + + Console.WriteLine(actual); + Assert.AreEqual(expected, actual); + } + + /// + /// Test a component with a parameter which isn't set + /// + [TestMethod] + public void ComponentBuilder_Parameters_TestIfModelNotSet() + { + // expected output + const string expected = "

No model!

"; + + var html = new ComponentRenderer() + .Render(); + + // trim leading space and trailing CRLF from output + var actual = html.Trim(); + + Console.WriteLine(actual); + Assert.AreEqual(expected, actual); + } + + #endregion Parameters + + #region Errors + + /// + /// Test rendering model with error (null reference is expected) + /// + [TestMethod] + public void ComponentRenderer_Error_Test() + { + var templater = new Templater(); + + // we should get a NullReferenceException thrown as Model parameter is not set + Assert.ThrowsException(() => + { + _ = new ComponentRenderer().Render(); + }); + } + + #endregion Errors + + #region Dependency Injection + + [TestMethod] + public void AddService_Test() + { + // set up + const int a = 2; + const int b = 3; + const int c = a + b; + string expected = $"

If you add {a} and {b} you get {c}

"; + + // fluent ComponentBuilder approach + var actual = new ComponentRenderer() + .AddService(new TestService()) + .Set(p => p.A, a) + .Set(p => p.B, b) + .Render(); + + Console.WriteLine(actual); + Assert.AreEqual(expected, actual); + } + + #endregion + + #region Nesting + + /// + /// Test that a component containing other components render correctly + /// + [TestMethod] + public void ComponentRenderer_Nested_Test() + { + // expected output + // the spaces before the

come from the Parameters.razor component + // on Windows the string contains \r\n and on unix it's just \n + string expected = $"Jan 1st is 2021-01-01{Environment.NewLine}

Dan Roth is cool!

"; + + var templater = new Templater(); + var model = new TestModel() + { + Name = "Dan Roth", + Description = "is cool" + }; + var html = new ComponentRenderer() + .Set(c => c.Model, model) + .Render(); + + // trim leading space and trailing CRLF from output + var actual = html.Trim(); + + Console.WriteLine(actual); + Assert.AreEqual(expected, actual); + } + + #endregion + + #region Cascading Values + + [TestMethod] + public void ComponentRenderer_CascadingValues_Test() + { + const string expected = "

The name is Bill

"; + var info = new CascadeInfo() { Name = "Bill" }; + + var html = new ComponentRenderer() + .Set(c => c.Info, info) + .Render(); + + // trim leading space and trailing CRLF from output + var actual = html.Trim(); + + Assert.AreEqual(expected, actual); + + } + + #endregion + + } +} \ No newline at end of file diff --git a/BlazorTemplater/BlazorTemplater.csproj b/BlazorTemplater/BlazorTemplater.csproj index c1d953e..95fc267 100644 --- a/BlazorTemplater/BlazorTemplater.csproj +++ b/BlazorTemplater/BlazorTemplater.csproj @@ -11,6 +11,7 @@ Blazor RazorComponents HTML Email Templating 1.2.0 Fixed issue with using library in .NET 5.0 + Latest diff --git a/BlazorTemplater/ComponentRenderer.cs b/BlazorTemplater/ComponentRenderer.cs new file mode 100644 index 0000000..0fd05a7 --- /dev/null +++ b/BlazorTemplater/ComponentRenderer.cs @@ -0,0 +1,127 @@ +using Microsoft.AspNetCore.Components; +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; + +namespace BlazorTemplater +{ + /* + * Adapted from ParameterViewBuilder.cs in Egil Hansen's Genzor + * https://github.com/egil/genzor/blob/main/src/genzor/ParameterViewBuilder.cs + * Thanks for the suggestion, Egil! + */ + + /// + /// Fluent component renderer + /// + /// Type of component to render + public class ComponentRenderer where TComponent : IComponent + { + private const string ChildContent = nameof(ChildContent); + private static readonly Type TComponentType = typeof(TComponent); + + private readonly Dictionary parameters = new(StringComparer.Ordinal); + private readonly Templater templater; + + #region Ctor + + /// + /// Create a new renderer + /// + public ComponentRenderer() + { + templater = new Templater(); + } + + #endregion Ctor + + #region Services + + /// + /// Fluent add-service with contract and implementation + /// + /// + /// + /// + /// + public ComponentRenderer AddService(TImplementation implementation) where TImplementation : TContract + + { + templater.AddService(implementation); + return this; + } + + /// + /// Fluent add-service with implemention + /// + /// + /// + /// + public ComponentRenderer AddService(TImplementation implementation) + + { + templater.AddService(implementation); + return this; + } + + #endregion Services + + #region Set Parameters + + /// + /// Sets the to the parameter selected with the . + /// + /// Type of . + /// A lambda function that selects the parameter. + /// The value to pass to . + /// This so that additional calls can be chained. + public ComponentRenderer Set(Expression> parameterSelector, TValue value) + { + if (value is null) + throw new ArgumentNullException(nameof(value)); + + parameters.Add(GetParameterName(parameterSelector), value); + return this; + } + + private static string GetParameterName(Expression> parameterSelector) + { + if (parameterSelector is null) + throw new ArgumentNullException(nameof(parameterSelector)); + + if (parameterSelector.Body is not MemberExpression memberExpression || + memberExpression.Member is not PropertyInfo propInfoCandidate) + throw new ArgumentException($"The parameter selector '{parameterSelector}' does not resolve to a public property on the component '{typeof(TComponent)}'.", nameof(parameterSelector)); + + var propertyInfo = propInfoCandidate.DeclaringType != TComponentType + ? TComponentType.GetProperty(propInfoCandidate.Name, propInfoCandidate.PropertyType) + : propInfoCandidate; + + var paramAttr = propertyInfo?.GetCustomAttribute(inherit: true); + + if (propertyInfo is null || paramAttr is null) + throw new ArgumentException($"The parameter selector '{parameterSelector}' does not resolve to a public property on the component '{typeof(TComponent)}' with a [Parameter] or [CascadingParameter] attribute.", nameof(parameterSelector)); + + return propertyInfo.Name; + } + + #endregion Set Parameters + + /// + /// Builds the with the parameters added to the builder. + /// + /// The created . + private ParameterView Build() => ParameterView.FromDictionary(parameters); + + /// + /// Render the component to HTML + /// + /// + public string Render() + { + // renders the component and returns the markup HTML + return templater.RenderComponent(Build()); + } + } +} \ No newline at end of file diff --git a/BlazorTemplater/ParameterViewBuilder.cs b/BlazorTemplater/ParameterViewBuilder.cs new file mode 100644 index 0000000..c6dbfb6 --- /dev/null +++ b/BlazorTemplater/ParameterViewBuilder.cs @@ -0,0 +1,75 @@ +//using Microsoft.AspNetCore.Components; +//using System; +//using System.Collections.Generic; +//using System.Linq; +//using System.Linq.Expressions; +//using System.Reflection; +//using System.Text; +//using System.Threading.Tasks; + +//namespace BlazorTemplater +//{ +// /// +// /// Represents a builder for a which can be +// /// safely passed to a component of type . +// /// +// /// The component type to build parameters for. +// public class ParameterViewBuilder where TComponent : IComponent +// { +// private const string ChildContent = nameof(ChildContent); +// private static readonly Type TComponentType = typeof(TComponent); + +// private readonly Dictionary parameters = new Dictionary(StringComparer.Ordinal); + +// /// +// /// Adds the to the parameter selected with the . +// /// +// /// Type of . +// /// A lambda function that selects the parameter. +// /// The value to pass to . +// /// This so that additional calls can be chained. +// public ParameterViewBuilder Add(Expression> parameterSelector, TValue value) +// { +// if (value is null) +// throw new ArgumentNullException(nameof(value)); + +// parameters.Add(GetParameterName(parameterSelector), value); +// return this; +// } + +// /// +// /// Adds the to the parameter selected with . +// /// +// /// A lambda function that selects the parameter. +// /// The content string to pass to the . +// /// This so that additional calls can be chained. +// public ParameterViewBuilder Add(Expression> parameterSelector, string content) +// => Add(parameterSelector, b => b.AddContent(0, content)); + +// /// +// /// Builds the with the parameters added to the builder. +// /// +// /// The created . +// public ParameterView Build() => ParameterView.FromDictionary(parameters); + +// private static string GetParameterName(Expression> parameterSelector) +// { +// if (parameterSelector is null) +// throw new ArgumentNullException(nameof(parameterSelector)); + +// if (!(parameterSelector.Body is MemberExpression memberExpression) || !(memberExpression.Member is PropertyInfo propInfoCandidate)) +// throw new ArgumentException($"The parameter selector '{parameterSelector}' does not resolve to a public property on the component '{typeof(TComponent)}'.", nameof(parameterSelector)); + +// var propertyInfo = propInfoCandidate.DeclaringType != TComponentType +// ? TComponentType.GetProperty(propInfoCandidate.Name, propInfoCandidate.PropertyType) +// : propInfoCandidate; + +// var paramAttr = propertyInfo?.GetCustomAttribute(inherit: true); + +// if (propertyInfo is null || paramAttr is null) +// throw new ArgumentException($"The parameter selector '{parameterSelector}' does not resolve to a public property on the component '{typeof(TComponent)}' with a [Parameter] or [CascadingParameter] attribute.", nameof(parameterSelector)); + +// return propertyInfo.Name; +// } +// } +//} diff --git a/BlazorTemplater/Templater.cs b/BlazorTemplater/Templater.cs index 05d5123..dfc3f06 100644 --- a/BlazorTemplater/Templater.cs +++ b/BlazorTemplater/Templater.cs @@ -32,9 +32,9 @@ public Templater() } /// - /// Lazy service collection instance + /// Service collection instance /// - private readonly ServiceCollection _serviceCollection = new ServiceCollection(); + private readonly ServiceCollection _serviceCollection = new(); /// /// Lazy HtmlRenderer instance @@ -106,10 +106,29 @@ public string RenderComponent(IDictionary parameters /// /// optional dictionary /// - private ParameterView GetParameterView(IDictionary parameters) + private static ParameterView GetParameterView(IDictionary parameters) { if (parameters == null) return ParameterView.Empty; return ParameterView.FromDictionary(parameters); } + + /// + /// Method for ComponentBuilder to use + /// + /// + /// + /// + internal string RenderComponent(ParameterView parameters) where TComponent : IComponent + { + // generate a render model + var component = new RenderedComponent(Renderer); + + // set the parameters + component.SetParametersAndRender(parameters); + + // get markup + return component.GetMarkup(); + } + } } \ No newline at end of file diff --git a/Docs/Usage.md b/Docs/Usage.md index 7b84bee..815c781 100644 --- a/Docs/Usage.md +++ b/Docs/Usage.md @@ -6,6 +6,51 @@ Create templates using `.razor` components in your code, e.g. `MyComponent.razor They default to the namespace of the folder they are in, but you can override this with the [`@namespace` directive](https://docs.microsoft.com/en-us/aspnet/core/blazor/components/?view=aspnetcore-5.0#namespaces). +### Fluent `ComponentRenderer` + +Create an instance of the `ComponentRenderer` class, where `T` is the component type. + +Then call either `.AddService()` to add services for dependency injection, or `.Set()` to set parameters on the component. You can call these multiple times for different services and parameters. + +The final call is `.Render()` which renders the component as a HTML string. + +#### Simple Render +To render a component which does not need any parameters set, you use the following code: +```c# +var html = new ComponentRenderer().Render(); +``` +#### Setting Component Parameters + +Parameters are set using the `.Set()` method and a lambda function using the component to indicate the name: +```c# +var html = new ComponentRenderer() + .Set(c => c.Model, myModel) + .Set(c => c.Title, "My title") + .Render(); +``` +#### Injecting Service Dependencies + +You can use [Dependency Injection](https://docs.microsoft.com/en-us/aspnet/core/blazor/fundamentals/dependency-injection) in Razor Components with the `@inject` directive. `ComponentRenderer` has a service container inside to provide these to the component, and you add these with `AddService()` + +```c# +var html = new ComponentRenderer() + .AddService(serviceInstance) + .AddService(anotherService) + .Render(); +``` +BlazorTemplater supports two overloads to `AddService`, as shown above. The first uses a contract type (`IServiceType`) plus an instance which is a different class that implements the contract; `serviceInstance` in the example. The second method injects a service value and the contract is inferred as the same as the value. + +In ASP.NET Core it's possible to use DI to chain dependencies via constructor injection. That isn't supported here and you need to instantiate your services manually. + +#### Errors + +Razor Components are classes that execute code, so if there is an error in your component, `Render()` will throw an exception. A common error is not setting parameters resulting in a `NullReferenceException`. + +### `Templater` Class + +The initial versions of BlazorTemplater used the `Templater` class. I've retained the instructions for that here for reference. It's used by `ComponentRenderer` so it's essentially the same, but doesn't have the fluent interface. + +#### `Templater` Create an instance of the `Templater` class. This is a rendering host that also acts as a service container. This instance can be reused multiple times provided the services to be injected are the same. Use the `.RenderComponent(..)` method to generate the HTML. @@ -59,7 +104,3 @@ In ASP.NET Core it's possible to use DI to chain dependencies via constructor in Razor Components are classes that execute code, so if there is an error in your component, `RenderComponent` will throw an exception. A common error is not setting parameters resulting in a `NullReferenceException`. -### Supported Features - -#### Nesting Components -Razor Components can be nested so you can use a more structured approach to designing your layout. \ No newline at end of file diff --git a/README.md b/README.md index 8eedd88..e9272c7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,53 @@ # BlazorTemplater A library that generates HTML (e.g. for emails) from [Razor Components](https://docs.microsoft.com/en-us/aspnet/core/blazor/components). -[![Build](https://github.com/conficient/BlazorTemplater/actions/workflows/dotnet-core.yml/badge.svg)](https://github.com/conficient/BlazorTemplater/actions/workflows/dotnet-core.yml) +[![Build](https://github.com/conficient/BlazorTemplater/actions/workflows/dotnet-core.yml/badge.svg)](https://github.com/conficient/BlazorTemplater/actions/workflows/dotnet-core.yml) [![Nuget](https://img.shields.io/nuget/dt/blazortemplater?logo=nuget&style=flat-square)](https://www.nuget.org/packages/blazortemplater/) #### Examples -Using the library is simple: + +The `ComponentRenderer` uses a [fluent interface](https://en.wikipedia.org/wiki/Fluent_interface). + +Let's render `MyComponent.razor` as a HTML string. +```c# +string html = new ComponentRenderer().Render(); +``` + +**Parameters** + +You can set parameters on a component: +```c# +var model = new Model() { Value = "Test" }; +var title = "Test"; +string html = new ComponentRenderer() + .Set(c => c.Model, model) + .Set(c => c.Title, title) + .Render(); +``` +MyComponent has a `Model` parameter and a `Title` parameter. The fluent interface uses a lambda expression to specify the property and ensures the value matches the property type. + +**Dependency Injection** + +You can specify services to be provided to a component that uses `@inject`, e.g.: +```c# +string html = new ComponentRenderer() + .AddService(new TestService()) + .Render(); +``` +#### The 'kitchen sink' +You can chain them all together in any order, provided `.Render()` is last: +```c# +var model = new Model() { Value = "Test" }; +var title = "Test"; +string html = new ComponentRenderer() + .Set(c => c.Title, title) + .AddService(new TestService()) + .Set(c => c.Model, model) + .Render(); +``` + +#### Template Method +You can also use the older templater method (retained for compatability): + ```c# var templater = new Templater(); var html = templater.RenderComponent(); @@ -41,9 +84,7 @@ Add the `BlazorTemplater` NUGET package to your library. ### Usage -Create an instance of the `Templater` class, and use `.RenderComponent()` to create the HTML. - -See the [Usage](Docs/usage) page for more detailed usage guidance. +See the [usage guide](Docs/Usage). ### Supported Project Types @@ -83,6 +124,7 @@ BlazorRenderer supports using: ### Limitations The following are not supported/tested: + - JavaScript - EventCallbacks - Rerendering - CSS and CSS isolation @@ -105,4 +147,6 @@ This was never developed into a functioning product or library. For unit testing | -------- |-----------| | v1.0.0 | Inital Release (to Nuget) | | v1.1.0 | **Breaking change**: renamed `BlazorTemplater` class to `Templater` [#4](https://github.com/conficient/BlazorTemplater/issues/4) | +| v1.2.0 | Added multi-targetting for .NET Std/.NET 5 to fix bug [#12](https://github.com/conficient/BlazorTemplater/issues/12) | +| v1.3.0 | Added `ComponentRenderer` for fluent interface and typed parameter setting |