Skip to content

Instantly share code, notes, and snippets.

@austins
Last active June 11, 2024 00:57
Show Gist options
  • Save austins/1a23b467de29989465aab7f5574e596c to your computer and use it in GitHub Desktop.
Save austins/1a23b467de29989465aab7f5574e596c to your computer and use it in GitHub Desktop.
Component tag helper providing ability to create components for Razor views in place of partials/view components
@addTagHelper *, ExampleApp
using System.Reflection;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace ExampleApp.TagHelpers;
/// <summary>
/// Tag helper for a component with support for properties and a slot (inner content).
/// </summary>
/// <remarks>
/// Do not call the component within itself in the view. Recursion is not supported.
/// </remarks>
public abstract class ComponentTagHelper : TagHelper
{
private readonly Type _instanceType;
private HtmlString? _slot;
protected ComponentTagHelper()
{
_instanceType = GetType();
}
[HtmlAttributeNotBound]
[ViewContext]
public ViewContext ViewContext { get; set; } = null!;
/// <summary>
/// Processes and initializes an instance of the tag helper.
/// </summary>
/// <param name="context">Tag helper context.</param>
/// <param name="output">Tag helper output.</param>
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
EnsureValidPropsOrThrow(output);
// Ensure the view exists first before proceeding.
var view = GetViewOrThrow();
await PrepareSlotAsync(output);
// There is a view, so we don't render a tag name.
output.TagName = null;
// Set the output to the rendered view.
output.Content.SetHtmlContent(await RenderViewAsync(view));
}
/// <summary>
/// Render a slot in the view.
/// </summary>
/// <returns>HTML content.</returns>
/// <exception cref="InvalidOperationException">No slot content.</exception>
public IHtmlContent Slot()
{
return _slot
?? throw new InvalidOperationException(
$"Component '{_instanceType!.Name}' requires slot content but none was given.");
}
private void EnsureValidPropsOrThrow(TagHelperOutput output)
{
// First, check for non-property attributes as it doesn't require reflection.
if (output.Attributes.Count > 0)
{
throw new InvalidOperationException($"Invalid attributes or props for '{_instanceType!.Name}'.");
}
// Check if any bound non-nullable properties aren't set as they are required.
var nullabilityInfoContext = new NullabilityInfoContext();
var missingRequiredProps = _instanceType!
.GetProperties(BindingFlags.Instance | BindingFlags.Public)
.Where(
x => x.GetSetMethod() is not null
&& nullabilityInfoContext.Create(x).WriteState is NullabilityState.NotNull
&& !Attribute.IsDefined(x, typeof(HtmlAttributeNotBoundAttribute), false)
&& x.GetValue(this) is null)
.Select(x => $"'{x.Name}'")
.ToList();
if (missingRequiredProps.Count > 0)
{
throw new InvalidOperationException(
$"Missing required props for '{_instanceType.Name}': {string.Join(", ", missingRequiredProps)}.");
}
}
/// <summary>
/// Get the view for the component. The view file must reside next to the code-behind and have the same name (without .cs).
/// </summary>
/// <returns>The view.</returns>
/// <exception cref="InvalidOperationException">View not found.</exception>
private IView GetViewOrThrow()
{
var viewEngine = ViewContext.HttpContext.RequestServices.GetRequiredService<ICompositeViewEngine>();
var result = viewEngine.GetView(
null,
$"{_instanceType!.Namespace![_instanceType.Assembly.GetName().Name!.Length..].Replace(".", "/", StringComparison.Ordinal)}/{_instanceType.Name}.cshtml",
false);
if (!result.Success)
{
throw new InvalidOperationException($"View could not be found for '{_instanceType.Name}'.");
}
return result.View;
}
/// <summary>
/// Prepare the slot HTML content. Content with only whitespace is ignored.
/// </summary>
/// <param name="output">Tag helper output.</param>
private async Task PrepareSlotAsync(TagHelperOutput output)
{
var childContent = await output.GetChildContentAsync();
var slotContent = childContent.GetContent().Trim();
if (!string.IsNullOrEmpty(slotContent))
{
_slot = new HtmlString(slotContent);
}
}
/// <summary>
/// Render the view and output the result to a string.
/// </summary>
/// <param name="view">The view.</param>
/// <returns>Rendered output of the view.</returns>
private async Task<string> RenderViewAsync(IView view)
{
var modelMetadataProvider =
ViewContext.HttpContext.RequestServices.GetRequiredService<IModelMetadataProvider>();
await using var writer = new StringWriter();
var componentViewContext = new ViewContext(
ViewContext,
view,
new ViewDataDictionary(modelMetadataProvider, ViewContext.ViewData.ModelState) { Model = this },
writer);
await view.RenderAsync(componentViewContext);
await writer.FlushAsync(ViewContext.HttpContext.RequestAborted);
return writer.ToString();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment