How to implement On Page Editing in Blazor
We already have some sites up and running on .NET 6/CMS 12 and with Blazor steadily improving with each release, we're now at a point where we're looking for an opportunity to use it in a customer project. We've evaluated potential issues that need to be adressed for this to happen, and one of the bigger ones that we identified is the lack of On Page Editing compatibility when a property is rendered within a Blazor Component.
When rendering a property in a component in a front-end framework, you're responsible for the rendering of the property - and you also need to make sure the property is updated when an editor changes the value in On Page Editing-mode. In Javascript frameworks, like Vue and ReactJs, this is rather straightforward: you add an attribute to the containing html-element of your property, like this:
<h1 data-epi-edit="Heading">
<!-- Render logic -->
</h1>
You then add Javascript code to subscribe to the event "contentSaved" and then update your component as needed.
Since Blazor can't really subscribe to a javascript-event directly, we need to add a little trickery to get it working - this example is for an implementation of AlloyTech on Blazor Server, but you could use the same principle for Blazor WebAssembly (you'd need to call Content Delivery API instead of using ContentLoader directly). To follow the steps outlined here, you should already have an Optimizely-site with Blazor up and running.
First, we need to create a file with a JS-event-listener that we're going to invoke through the JS-interoperability feature of Blazor. I created a file called opeListener.js with this little function:
function opeListener(dotnethelper) {
epi.subscribe("contentSaved", function (details) {
dotnethelper.invokeMethodAsync('RefreshOnSave', details);
});
}
The parameter "dotnethelper" here actually contains a reference to the .NET object that invoked the JS-function, which in turn allows the event listener to invoke a function on that object ("RefreshOnSave" in this case).
I then refered the script in _Root.cshtml, just below the inclusion of the Blazor Server-JS:
<script src="_framework/blazor.server.js"></script>
<script src="~/js/opeListener.js"></script>
We need a way for our .NET-code to hook on to this event listener, so what I did was to create a class for this purpose:
public class OnPageEditingService
{
private IJSRuntime js = null;
public event EventHandler Refresh;
public async void Init(IJSRuntime jsRuntime)
{
if (js == null)
{
js = jsRuntime;
await js.InvokeAsync<string>("opeListener", DotNetObjectReference.Create(this));
}
}
[JSInvokable]
public void RefreshOnSave()
{
Refresh?.Invoke(this, new EventArgs());
}
}
In the Init method we use JS-interoperability to call the listener we created in the previous step. We also have a method (invokable from JS through the [JSInvokable]-attribute) that triggers an event that will tell our Blazor component to refresh.
The Blazor component itself looks like this:
@inject IContentLoader contentLoader;
@inject IContentVersionRepository contentVersionRepository;
@inject IJSRuntime js;
@inject OnPageEditingService opeService;
@if(currentPage?.Heading != null)
{
<h1 data-epi-edit="Heading">@currentPage.Heading</h1>
}
@if(currentPage?.MainBody != null){
<div data-epi-edit="MainBody">
@((MarkupString)currentPage.MainBody.ToString())
</div>
}
@code {
[Parameter]
public int ContentLinkId { get; set; }
[Parameter]
public bool EditMode { get; set; }
private BlazorTestPage currentPage { get; set; }
private string contextMode = string.Empty;
protected override void OnInitialized()
{
@if (EditMode)
{
currentPage = GetLatestDraft();
}
else
{
currentPage = contentLoader.Get<BlazorTestPage>(new ContentReference(ContentLinkId));
}
opeService.Refresh += RefreshComponent;
base.OnInitialized();
}
protected override Task OnAfterRenderAsync(bool firstRender)
{
opeService.Init(js);
return base.OnAfterRenderAsync(firstRender);
}
protected async void RefreshComponent(object sender, EventArgs e)
{
currentPage = GetLatestDraft();
await InvokeAsync(() => StateHasChanged());
}
private BlazorTestPage GetLatestDraft()
{
var currentVersion = contentVersionRepository.List(new ContentReference(ContentLinkId))
.OrderByDescending(x => x.Saved)
.FirstOrDefault();
return contentLoader.Get<BlazorTestPage>(currentVersion.ContentLink);
}
}
It's a fairly simple component that renders two properties - Heading and MainBody. It takes two parameters - the ID of the content (trying to pass the PageData-object directly from the MVC view will result in JSON-serialization issues that I've had no luck trying to resolve) and a bool that tells us whether we are in EditMode or not, so we only render the draft if we are. The reason we are not using the ContextModeResolver directly in the component for this is that it relies on HttpContext which is unreliable to use in a Blazor Component.
There's not much logic here, but a couple of things to note: We wait until the OnAfterRender-lifecycle event to initiaize our OnPageEditingService - we won't be able to invoke our JS-listener before then.
Also, in OnInitialized, we hook up the RefreshComponent-method that will fire when the Refresh-event is triggered. For simplicity, we're doing a full reload of currentPage here, but if you really wanted, you could do a selective update, since the details of what properties were changed is available from our JS-event-listener. To retrieve that data in our OnPageEditingService, we could modify the RefreshOnSave as follows:
[JSInvokable]
public void RefreshOnSave(JsonElement details)
{
// Logic (make some custom event args and pass to Refresh.Invoke)
}
I hope this can help to kick start any budding Blazor projects out there :) Let me know in the comments if you have any questions!
Great content and good findings Jörgen!
Here is some more regarding Getting started with Blazor and MVC https://devblog.gosso.se/2022/04/blazor-server-mvc-with-optimizely/
We should figure out how to pass a block or page model as parameter, as you write i did also got this serialization circular reference... (It sucks that blazor is serializing parameters/models, why? why not pass it as reference since its the same runtime i wonder. - probably so save state)
When i asked Optimizely Support they answered me this:
Let me know here if someone solves this mistery...
Thanks! I already saw your article, great stuff!
I think there must be a difference in how objects are serialized when using the component tag helper to pass parameters from a razor view - as opposed to the SignalR transport which also uses JSON serialization by default as I understand, but has no problems with complex objects.
Anyway, I'm not sure that retrieving full IContent-objects from the Blazor front-end is neccessarily the way to go, even though SignalR should be massively more efficient than doing API-requests, there's still a concern that the data overhead poses a problem for slow connections so that view models may be more optimal, but I'd like to see some data on this - maybe it could be a topic for a blog post :)