Skip to content

Commit

Permalink
Added fluent ComponentRenderer type - fixes #12
Browse files Browse the repository at this point in the history
  • Loading branch information
conficient committed Apr 18, 2021
1 parent 040e8b2 commit 5ae7103
Show file tree
Hide file tree
Showing 7 changed files with 530 additions and 12 deletions.
211 changes: 211 additions & 0 deletions BlazorTemplater.Tests/ComponentRenderer_Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
using BlazorTemplater.Library;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System;

namespace BlazorTemplater.Tests
{
/// <summary>
///
/// </summary>
[TestClass]
public class ComponentRenderer_Tests
{
[TestMethod]
public void Ctor_Test()
{
var builder = new ComponentRenderer<Simple>();

Assert.IsNotNull(builder);
}

#region Simple render

/// <summary>
/// Render a component (no service injection or parameters)
/// </summary>
[TestMethod]
public void Simple_Test()
{
const string expected = @"<b>Jan 1st is 2021-01-01</b>";
var actual = new ComponentRenderer<Simple>()
.Render();

Console.WriteLine(actual);
Assert.AreEqual(expected, actual);
}

#endregion Simple render

#region Parameters

/// <summary>
/// Test a component with a parameter
/// </summary>
[TestMethod]
public void ComponentBuilder_Parameters_Test()
{
// expected output
const string expected = "<p>Steve Sanderson is awesome!</p>";

var model = new TestModel()
{
Name = "Steve Sanderson",
Description = "is awesome"
};

var html = new ComponentRenderer<Parameters>()
.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);
}

/// <summary>
/// Test a component with a parameter
/// </summary>
[TestMethod]
public void ComponentBuilder_Parameters_TestHtmlEncoding()
{
// expected output
const string expected = "<p>Safia &amp; Pranav are awesome too!</p>";

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<Parameters>()
.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);
}

/// <summary>
/// Test a component with a parameter which isn't set
/// </summary>
[TestMethod]
public void ComponentBuilder_Parameters_TestIfModelNotSet()
{
// expected output
const string expected = "<p>No model!</p>";

var html = new ComponentRenderer<Parameters>()
.Render();

// trim leading space and trailing CRLF from output
var actual = html.Trim();

Console.WriteLine(actual);
Assert.AreEqual(expected, actual);
}

#endregion Parameters

#region Errors

/// <summary>
/// Test rendering model with error (null reference is expected)
/// </summary>
[TestMethod]
public void ComponentRenderer_Error_Test()
{
var templater = new Templater();

// we should get a NullReferenceException thrown as Model parameter is not set
Assert.ThrowsException<NullReferenceException>(() =>
{
_ = new ComponentRenderer<ErrorTest>().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 = $"<p>If you add {a} and {b} you get {c}</p>";

// fluent ComponentBuilder approach
var actual = new ComponentRenderer<ServiceInjection>()
.AddService<ITestService>(new TestService())
.Set(p => p.A, a)
.Set(p => p.B, b)
.Render();

Console.WriteLine(actual);
Assert.AreEqual(expected, actual);
}

#endregion

#region Nesting

/// <summary>
/// Test that a component containing other components render correctly
/// </summary>
[TestMethod]
public void ComponentRenderer_Nested_Test()
{
// expected output
// the spaces before the <p> come from the Parameters.razor component
// on Windows the string contains \r\n and on unix it's just \n
string expected = $"<b>Jan 1st is 2021-01-01</b>{Environment.NewLine} <p>Dan Roth is cool!</p>";

var templater = new Templater();
var model = new TestModel()
{
Name = "Dan Roth",
Description = "is cool"
};
var html = new ComponentRenderer<NestedComponents>()
.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 = "<p>The name is Bill</p>";
var info = new CascadeInfo() { Name = "Bill" };

var html = new ComponentRenderer<CascadeParent>()
.Set(c => c.Info, info)
.Render();

// trim leading space and trailing CRLF from output
var actual = html.Trim();

Assert.AreEqual(expected, actual);

}

#endregion

}
}
1 change: 1 addition & 0 deletions BlazorTemplater/BlazorTemplater.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<PackageTags>Blazor RazorComponents HTML Email Templating</PackageTags>
<Version>1.2.0</Version>
<PackageReleaseNotes>Fixed issue with using library in .NET 5.0</PackageReleaseNotes>
<LangVersion>Latest</LangVersion>
</PropertyGroup>

<ItemGroup>
Expand Down
127 changes: 127 additions & 0 deletions BlazorTemplater/ComponentRenderer.cs
Original file line number Diff line number Diff line change
@@ -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!
*/

/// <summary>
/// Fluent component renderer
/// </summary>
/// <typeparam name="TComponent">Type of component to render</typeparam>
public class ComponentRenderer<TComponent> where TComponent : IComponent
{
private const string ChildContent = nameof(ChildContent);
private static readonly Type TComponentType = typeof(TComponent);

private readonly Dictionary<string, object> parameters = new(StringComparer.Ordinal);
private readonly Templater templater;

#region Ctor

/// <summary>
/// Create a new renderer
/// </summary>
public ComponentRenderer()
{
templater = new Templater();
}

#endregion Ctor

#region Services

/// <summary>
/// Fluent add-service with contract and implementation
/// </summary>
/// <typeparam name="TContract"></typeparam>
/// <typeparam name="TImplementation"></typeparam>
/// <param name="implementation"></param>
/// <returns></returns>
public ComponentRenderer<TComponent> AddService<TContract, TImplementation>(TImplementation implementation) where TImplementation : TContract

{
templater.AddService<TContract, TImplementation>(implementation);
return this;
}

/// <summary>
/// Fluent add-service with implemention
/// </summary>
/// <typeparam name="TImplementation"></typeparam>
/// <param name="implementation"></param>
/// <returns></returns>
public ComponentRenderer<TComponent> AddService<TImplementation>(TImplementation implementation)

{
templater.AddService<TImplementation>(implementation);
return this;
}

#endregion Services

#region Set Parameters

/// <summary>
/// Sets the <paramref name="value"/> to the parameter selected with the <paramref name="parameterSelector"/>.
/// </summary>
/// <typeparam name="TValue">Type of <paramref name="value"/>.</typeparam>
/// <param name="parameterSelector">A lambda function that selects the parameter.</param>
/// <param name="value">The value to pass to <typeparamref name="TComponent"/>.</param>
/// <returns>This <see cref="ComponentRenderer{TComponent}"/> so that additional calls can be chained.</returns>
public ComponentRenderer<TComponent> Set<TValue>(Expression<Func<TComponent, TValue>> parameterSelector, TValue value)
{
if (value is null)
throw new ArgumentNullException(nameof(value));

parameters.Add(GetParameterName(parameterSelector), value);
return this;
}

private static string GetParameterName<TValue>(Expression<Func<TComponent, TValue>> 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<ParameterAttribute>(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

/// <summary>
/// Builds the <see cref="ParameterView"/> with the parameters added to the builder.
/// </summary>
/// <returns>The created <see cref="ParameterView"/>.</returns>
private ParameterView Build() => ParameterView.FromDictionary(parameters);

/// <summary>
/// Render the component to HTML
/// </summary>
/// <returns></returns>
public string Render()
{
// renders the component and returns the markup HTML
return templater.RenderComponent<TComponent>(Build());
}
}
}
Loading

0 comments on commit 5ae7103

Please sign in to comment.