Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added fluent ComponentRenderer type - fixes #2 #14

Merged
merged 1 commit into from
Apr 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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