Last active
November 3, 2024 23:01
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@addTagHelper *, ExampleApp |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 component '{_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 component '{_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('.', '/')}/{_instanceType.Name}.cshtml", | |
false); | |
if (!result.Success) | |
{ | |
throw new InvalidOperationException($"View could not be found for component '{_instanceType.Name}'."); | |
} | |
return result.View; | |
} | |
/// <summary> | |
/// Prepare the slot HTML content. Content with only whitespace is ignored. | |
/// </summary> | |
/// <remarks> | |
/// Does not render content if the slot contains another tag helper. Would probably need to rework this to use IHtmlHelper.PartialAsync(). | |
/// </remarks> | |
/// <param name="output">Tag helper output.</param> | |
private async Task PrepareSlotAsync(TagHelperOutput output) | |
{ | |
var slotContent = (await output.GetChildContentAsync()).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