Reputation: 1628
I have created a few Razor Components that are in a RCL and I would like to load and display them at runtime. I am aware that I can load assemblies using reflection, however I want the page to automatically update the menu and display the proper component. This should be able to be done by simply dropping the DLL in the specified directory.
So far I have determined that each page should have it's own class that implements an interface to ensure each page has the necessary information. The interface that I have come up with is
public interface IDynamicComponent
{
IDictionary<string,string> Parameters { get; }
string Name { get; }
string Page { get; }
Type Component { get;}
MenuItem MenuData { get; }
}
And I am able to load this into memory by using the following:
public IEnumerable<Type> LoadComponents(string path)
{
var components = new List<Type>();
var assemblies = LoadAssemblies(path);
foreach (var asm in assemblies)
{
var types = GetTypesWithInterface(asm);
foreach (var typ in types) components.Add(typ);
}
Components = components;
}
private IEnumerable<Type> GetTypesWithInterface(Assembly asm)
{
var it = typeof(IDynamicComponent);
return GetLoadableTypes(asm).Where(it.IsAssignableFrom).ToList();
}
private IEnumerable<Type> GetLoadableTypes(Assembly assembly)
{
if (assembly == null) throw new ArgumentNullException("assembly");
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException e)
{
return e.Types.Where(t => t != null);
}
}
But how do I go about updating the UI (the page and the navigation menu) to reflect these components?
Upvotes: 2
Views: 3292
Reputation: 1628
This is something I was trying to accomplish the other day and figured I'd post here to document for others if anyone else is running into a similar issue. The first step I took to resolving this was to create a new .net standard 2.0 project and add in these items:
An IComponentService interface to allow for easy DI injection, and possibly different implementations should the need arise
public interface IComponentService
{
void LoadComponents(string path);
IDynamicComponent GetComponentByName(string name);
IDynamicComponent GetComponentByPage(string name);
IEnumerable<Type> Components { get; }
IEnumerable<MenuItem> GetMenuItems(bool getHiddenItems = false);
}
The implementation of the IComponentService, this is primarily used for loading the components/pages and keeping track of them.
public class ComponentService : IComponentService
{
public IEnumerable<Type> Components { get; private set; }
public void LoadComponents(string path)
{
var components = new List<Type>();
var assemblies = LoadAssemblies(path);
foreach (var asm in assemblies)
{
var types = GetTypesWithInterface(asm);
foreach (var typ in types) components.Add(typ);
}
Components = components;
}
public IEnumerable<MenuItem> GetMenuItems(bool getHiddenItems = false)
{
var components = Components.Select(x => (IDynamicComponent) Activator.CreateInstance(x));
if (!getHiddenItems)
components = components.Where(x => x.MenuData.Display);
return components.Select(x=>x.MenuData);
}
public IDynamicComponent GetComponentByName(string name)
{
return Components.Select(x => (IDynamicComponent) Activator.CreateInstance(x))
.SingleOrDefault(x => x.Name == name);
}
public IDynamicComponent GetComponentByPage(string name)
{
return Components.Select(x => (IDynamicComponent) Activator.CreateInstance(x))
.SingleOrDefault(x => x.Page == name);
}
private IEnumerable<Assembly> LoadAssemblies(string path)
{
return Directory.GetFiles(path, "*.dll").Select(dll => Assembly.LoadFile(dll)).ToList();
}
private IEnumerable<Type> GetTypesWithInterface(Assembly asm)
{
var it = typeof(IDynamicComponent);
return GetLoadableTypes(asm).Where(it.IsAssignableFrom).ToList();
}
private IEnumerable<Type> GetLoadableTypes(Assembly assembly)
{
if (assembly == null) throw new ArgumentNullException("assembly");
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException e)
{
return e.Types.Where(t => t != null);
}
}
}
The IDynamicComponent interface for which there should be one implementation for each page that you want loaded
public interface IDynamicComponent
{
IDictionary<string,string> Parameters { get; }
string Name { get; }
string Page { get; }
Type Component { get;}
MenuItem MenuData { get; }
}
And a simple MenuItem class that will contain information for the navigation menu
public class MenuItem
{
public bool Display { get; set; }
public string Text { get; set; }
public string Page { get; set; }
public string Icon { get; set; }
public string CSS { get; set; }
}
Setting up the Component
Next step was to set up the page. I started by taking the built-in demo WeatherForecast and moving all associated files to a separate project as a RCL. Following this I modified the .razor file to not inject the WeatherForecastService but rather instantiate a new copy of it as shown below:
@code {
[Parameter]
public string Name { get; set; }
private WeatherForecast[] forecasts;
private WeatherForecastService WeatherForecastService;
protected override async Task OnInitializedAsync()
{
WeatherForecastService = new WeatherForecastService();
forecasts = await WeatherForecastService.GetForecastAsync(DateTime.Now);
}
}
Next I created a class called MyComponent and added it to the project containing the WeatherForecast
public class MyComponent : IDynamicComponent
{
public bool DisplayInMenu => true;
public IDictionary<string,string> Parameters => new Dictionary<string,string>
{
{"Name","My Weather Forecast"}
};
public string Name => "Weather Forecast";
public string Page => "Forecast";
public Type Component => typeof(Component2);
public MenuItem MenuData => new MenuItem
{
Display = true,
Page = Page,
CSS = String.Empty,
Text = "Data",
Icon = "oi oi-list-rich"
};
}
It is important to note that the Parameters dictionary contains an entry called "Name" which is the name of the Parameter for the WeatherForecast page. This allows us to change and inject different parameters at runtime. The "Page" property is to create the url for the page (ex. /Forecast /Counter etc)
Fixing the base project
Once the component was setup and the other project containing the componentservice I had to modify the base Blazor project to take advantage of these changes.
First I started by adding the IComponentService to the DI container by adding the following code to the ConfigureServices method in the Startup.cs file
services.AddSingleton<IComponentService>(_ =>
{
var service = new ComponentService();
service.LoadComponents(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location));
return service;
});
Next I created a simple extension method that would convert a MenuItem into a component using the RenderFragment builder
public static RenderFragment GenerateMenuItem(this MenuItem item)
{
RenderFragment fragment = builder =>
{
builder.OpenElement(3, "li");
builder.AddAttribute(4,"class","nav-item px-3");
builder.OpenComponent<NavLink>(4);
builder.AddAttribute(6,"class","nav-link");
builder.AddAttribute(7, "href", $"/{item.Page}");
builder.AddAttribute(8, "Match", NavLinkMatch.All);
builder.AddAttribute(9, "ChildContent", (RenderFragment)((builder2) => {
builder2.AddMarkupContent(10, $"<span class=\"{item.Icon}\" aria-hidden=\"true\"></span>");
builder2.AddContent(11, item.Text);
}));
builder.CloseComponent();
builder.CloseElement();
};
return fragment;
}
The next phase was to modify the navigation menu to load all components by generating the renderfragments from the MenuItems and showing them. In NavMenu.razor I edited the file to match this:
@using Component.Common
@inject IComponentService ComponentService
<div class="top-row pl-4 navbar navbar-dark">
<a class="navbar-brand" href="">BlazorComponentHotloadDemo</a>
<button class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
<div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
<ul class="nav flex-column">
<li class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink>
</li>
@if (menuItems != null)
{
foreach (var fragment in menuItems)
@fragment;
}
</ul>
</div>
@code {
IEnumerable<RenderFragment> menuItems;
private bool collapseNavMenu = true;
private string NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
protected override void OnInitialized()
{
var items = ComponentService.GetMenuItems();
var menulist = new List<RenderFragment>();
foreach (var item in items)
{
menulist.Add(item.GenerateMenuItem());
}
menuItems = menulist;
base.OnInitialized();
}
}
And for the final step I created a new page in the Pages directory called ComponentPage to display the new page. This is done by utilizing the RenderFragment builder. We open the page and add any parameters then display the results on the page.
@page "/{componentName}"
@using Component.Common
@inject IComponentService ComponentService
@dynamicComonent()
@code{
[Parameter]
public string componentName { get; set; }
RenderFragment dynamicComonent() => builder =>
{
var component = ComponentService.GetComponentByPage(componentName);
builder.OpenComponent(0,component.Component);
for (int i = 0; i < component.Parameters.Count; i++)
{
var attribute = component.Parameters.ElementAt(i);
builder.AddAttribute(i+1,attribute.Key,attribute.Value);
}
builder.CloseComponent();
};
}
The result was being able to load full pages and modify the navmenu at runtime.
Upvotes: 2