Migrating a Standard Blazor Application to Oqtane

October 01, 2024

By: Shaun Walker

One of the common questions which developers ask when they encounter Oqtane is "can I utilize this framework with my existing Blazor applications?". The short answer to that question is "yes" as Oqtane is a native Blazor application. However you do need to keep in mind that features such as mult-tenancy and modularity are architectural concepts which require a specific implementation approach. So you cannot simply add an Oqtane Nuget reference to your existing project and expect that all of the features will be magically activated in your existing project.

If we use the default Blazor Web App template in .NET 8 as an example, we can explore the steps to migrate a standard Blazor application to Oqtane. For this tutorial we will focus on the classic Weather page as it has the most practical functionality from a real world perspective.

Weather Tutorial

The first thing we need to do is set up our Oqtane development environment. The simplest approach is to clone the Oqtane repo into a folder on your local machine, open the Oqtane.sln file using Visual Studio, perform a full build (Build / Rebuild Solution) and then run the application to complete the installation.

Oqtane is a modular application framework which means that new functionality is developed and deployed independently from the framework as discreet modules. A module in Oqtane is essentially a standard Razor Class Library which is packaged using Nuget. A Razor Class Library can contain an unlimited number of Razor components, as well as back-end API services. At run-time, the loosely coupled components and services will be discovered and loaded dynamically to compose your application. So, in order to migrate the Weather page to Oqtane we will need to create a new custom module.

Once Oqtane is up and running, you can login using the host account you created during installation. Use the Control Panel icon at the top right to access the Admin Dashboard and choose the Module Management option. Select the Create Module option and enter your organization name, specify "Weather" for the module name, select the Default Module Template, and choose the latest version number in the list (rather than Installed Version). Select Create Module and use Windows File Manager to navigate to the location specified and open the solution file in Visual Studio.

Weather Tutorial

The Default Module Template includes logic which allows a module to run on all Blazor render modes and hosting models and take advantage of many of the features available in Oqtane. If we simply want to replicate the functionality of the Weather page in the Blazor Web App template then we only need to migrate a single razor component - Weather.razor.

One of the main differences between Oqtane and the standard Blazor approach for developing applications is that Oqtane is architected from the ground up on modular principles. This means that it does not use the default Blazor router, as the default Blazor router relies on static "page" routes obtained by using reflection to interogate razor components for the existence of an @page attribute. This approach provides a tight coupling beteen the router and page components which has a variety of limitations. In contrast, Oqtane uses a custom router which provides an abstraction between routes and components. This provides more flexibiity and reusability of components amd allows routes to be managed dynamically at run-time.

Oqtane modules use a convention where the default module component is named Index.razor. So we will migrate the contents of the Weather.razor component to Index.razor. Since Oqtane module components are standard Blazor components, the changes required are minimal.

Weather Tutorial

Since the @page attribute will be ignored in Oqtane it can be removed. And since the <PageTitle> component used in many Blazor components is only intended to be used in "page" components, it can be removed as well. The <h1> element is not required as the module will be wrapped in a "container" which provides the title of the component dynamically based on configuration. We will retain the @attribute [StreamRendering] to leverage the stream rendering capability in .NET 8. In order for Oqtane module components to gain access to features such as multi-tenancy, etc... components should inherit from the ModuleBase class. And since Oqtane supports both interactive and static render modes, we are going to want to override the RenderMode property to instruct the framework that this component will use static rendering:

    
@namespace Siliqon.Module.Weather
@attribute [StreamRendering]
@inherits ModuleBase

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    public override string RenderMode => RenderModes.Static;

    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        // Simulate asynchronous loading to demonstrate streaming rendering
        await Task.Delay(500);

        var startDate = DateOnly.FromDateTime(DateTime.Now);
        var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
        forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = startDate.AddDays(index),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = summaries[Random.Shared.Next(summaries.Length)]
            }).ToArray();
    }

    private class WeatherForecast
    {
        public DateOnly Date { get; set; }
        public int TemperatureC { get; set; }
        public string? Summary { get; set; }
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}
    

Perform a Build / Rebuild on the module solution, ensure that Oqtane.Server is specified as the Startup Project, and hit F5 to run the application. At this point, the module exists in your installation but it needs to be added to a page for it to be visible. This requires us to login to the application, open the Control Panel (top right), select the Weather module from the list of modules, and click Add Module.

Weather Tutorial

Now that the module is functional in Oqtane, we can make some enhancements to make this module behave more like a real world application. Specifically, we will migrate the Weather service logic to the Server so that it is architected like a real client/server application.

First we are going to move the WeatherForecast model class from the Index.razor to the Shared project in the Models folder. This will allow the model to be available to both the Client and Server. We will not change the existing Weather model as this would have an impact to other aspects of the project we would like to use for guidance. Note that once we remove the WeatherForecast class from Index.razor we need to add a @using Siliqon.Module.Weather.Models so that the component can find the model in the new location.

    
using System;

namespace Siliqon.Module.Weather.Models
{
    public class WeatherForecast
    {
        public DateOnly Date { get; set; }
        public int TemperatureC { get; set; }
        public string Summary { get; set; }
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}
    

Next we are going to create an interface for our WeatherForecastService in the Shared project and Interfaces folder. Note that we are going to return a List of objects rather than the array used in the original code.

    
using System.Collections.Generic;
using System.Threading.Tasks;

namespace Siliqon.Module.Weather.Services
{
    public interface IWeatherForecastService 
    {
        Task<List<Models.WeatherForecast>> GetWeatherForecastsAsync(int ModuleId);
    }
}
    

And we are going to create the implementation for this interface in the Server project in the Services folder. The implementation logic for generating random forecasts has been migrated from Index.razor.

    
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Oqtane.Enums;
using Oqtane.Infrastructure;
using Oqtane.Models;
using Oqtane.Security;
using Oqtane.Shared;
using Siliqon.Module.Weather.Models;

namespace Siliqon.Module.Weather.Services
{
    public class WeatherForecastService : IWeatherForecastService
    {
        private readonly IUserPermissions _userPermissions;
        private readonly ILogManager _logger;
        private readonly IHttpContextAccessor _accessor;
        private readonly Alias _alias;

        public WeatherForecastService(IUserPermissions userPermissions, ITenantManager tenantManager, ILogManager logger, IHttpContextAccessor accessor)
        {
            _userPermissions = userPermissions;
            _logger = logger;
            _accessor = accessor;
            _alias = tenantManager.GetAlias();
        }

        public Task<List<Models.WeatherForecast>> GetWeatherForecastsAsync(int ModuleId)
        {
            if (_userPermissions.IsAuthorized(_accessor.HttpContext.User, _alias.SiteId, EntityNames.Module, ModuleId, PermissionNames.View))
            {
                var startDate = DateOnly.FromDateTime(DateTime.Now);
                var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
                return Task.FromResult(Enumerable.Range(1, 5).Select(index => new WeatherForecast
                {
                    Date = startDate.AddDays(index),
                    TemperatureC = Random.Shared.Next(-20, 55),
                    Summary = summaries[Random.Shared.Next(summaries.Length)]
                }).ToList());                
            }
            else
            {
                _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Weather Forecasts Get Attempt {ModuleId}", ModuleId);
                return null;
            }
        }
    }
}
    

We want Oqtane to register this service for us automatically when we run the application, so we will need to modify the ServerStartup.cs in the Startup folder in the Server project to include a reference to our new service:

    
services.AddTransient<IWeatherForecastService, WeatherForecastService>();
    

And of course, Index.razor must be updated to use the new service:

    
@using Siliqon.Module.Weather.Models
@using Siliqon.Module.Weather.Services

@namespace Siliqon.Module.Weather
@attribute [StreamRendering]
@inherits ModuleBase
@inject IWeatherForecastService WeatherForecastService

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    public override string RenderMode => RenderModes.Static;

    private List<WeatherForecast> forecasts;

    protected override async Task OnInitializedAsync()
    {
        // Simulate asynchronous loading to demonstrate streaming rendering
        await Task.Delay(500);

        forecasts = await WeatherForecastService.GetWeatherForecastsAsync(ModuleState.ModuleId);
    }
}
    

If you Build / Rebuild the code at this point and then run the application, you will see that random weather forecast is still displayed in the UI... however the data is now being generated on the server rather than the client. We can even add another instance of the Weather module to our page by opening the Control Panel (top right), selecting the Weather module from the list of modules, and clicking Add Module. Now we have 2 independent instances of the component on our page, and we did not have to modify or deploy any code.

Weather Tutorial

But what if we wanted to use "real" weather data rather than random data? We would need a UI to manage the weather forecast information and we would need a service for saving and retrieving the information from the database. This sounds like a major enhancement, however this is where Oqtane can really accelerate the Blazor development process.

First let's enhance the functionality of our service. We will add another method to the IWeatherForecastService interface we created earlier:

    
Task UpdateWeatherForecastsAsync(int ModuleId, List<Models.WeatherForecast> WeatherForecasts);
    

And we will change the implementation of the WeatherForecastService so that it saves and retrieves information from a database. Our implementation will use Oqtane's Setting table as a convenient location to store the weather forecast information as a serialized Json object.

    
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Oqtane.Enums;
using Oqtane.Infrastructure;
using Oqtane.Models;
using Oqtane.Repository;
using Oqtane.Security;
using Oqtane.Shared;
using Siliqon.Module.Weather.Models;

namespace Siliqon.Module.Weather.Services
{
    public class WeatherForecastService : IWeatherForecastService
    {
        private readonly ISettingRepository _settingRepository;
        private readonly IUserPermissions _userPermissions;
        private readonly ILogManager _logger;
        private readonly IHttpContextAccessor _accessor;
        private readonly Alias _alias;

        public WeatherForecastService(ISettingRepository settingRepository, IUserPermissions userPermissions, ITenantManager tenantManager, ILogManager logger, IHttpContextAccessor accessor)
        {
            _settingRepository = settingRepository;
            _userPermissions = userPermissions;
            _logger = logger;
            _accessor = accessor;
            _alias = tenantManager.GetAlias();
        }

        public Task<List<Models.WeatherForecast>> GetWeatherForecastsAsync(int ModuleId)
        {
            if (_userPermissions.IsAuthorized(_accessor.HttpContext.User, _alias.SiteId, EntityNames.Module, ModuleId, PermissionNames.View))
            {
                var weatherForecasts = new List<Models.WeatherForecast>();                
                var setting = _settingRepository.GetSetting(EntityNames.Module, ModuleId, "WeatherForecasts");
                if (setting != null)
                {
                    weatherForecasts = JsonSerializer.Deserialize<List<WeatherForecast>>(setting.SettingValue);
                }
                return Task.FromResult(weatherForecasts);
            }
            else
            {
                _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Weather Forecasts Get Attempt {ModuleId}", ModuleId);
                return null;
            }
        }

        public Task UpdateWeatherForecastsAsync(int ModuleId, List<Models.WeatherForecast> WeatherForecasts)
        {
            if (_userPermissions.IsAuthorized(_accessor.HttpContext.User, _alias.SiteId, EntityNames.Module, ModuleId, PermissionNames.Edit))
            {
                var setting = _settingRepository.GetSetting(EntityNames.Module, ModuleId, "WeatherForecasts");
                if (setting == null)
                {
                    setting = new Setting { EntityName = EntityNames.Module, EntityId = ModuleId, SettingName = "WeatherForecasts", SettingValue = JsonSerializer.Serialize(WeatherForecasts), IsPrivate = false };
                    _settingRepository.AddSetting(setting);
                }
                else
                {
                    setting.SettingValue = JsonSerializer.Serialize(WeatherForecasts);
                    _settingRepository.UpdateSetting(setting);
                }
                _logger.Log(LogLevel.Information, this, LogFunction.Update, "Weather Forecasts Updated {WeatherForecasts}", WeatherForecasts);
            }
            else
            {
                _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Weather Forecasts Update Attempt {WeatherForecasts}", WeatherForecasts);
            }
            return Task.CompletedTask;
        }
    }
}
    

We will modify the existing Edit.razor component in the Client project to include an editable grid where an authorized user can add, edit, or delete weather forecast information. The grid will use a Pager component which is provided as part of the Oqtane framework. The SecurityAccessLevel property of the component has been overridden to indicate that only users who have Edit permissions for the module can access this component. The component will be displayed inside of a modal dialog by default as this is the UI convention for administrative components.

    
@using Oqtane.Modules.Controls
@using Siliqon.Module.Weather.Services
@using Siliqon.Module.Weather.Models

@namespace Siliqon.Module.Weather
@inherits ModuleBase
@inject IWeatherForecastService WeatherForecastService
@inject NavigationManager NavigationManager

<button type="button" class="btn btn-primary" @onclick="Add">Add</button>
<br /><br />

<Pager Items="@forecasts" PageSize="5" CurrentPage="@currentpage.ToString()" OnPageChange="OnPageChange">
    <Header>
        <th style="width: 1px;">&nbsp;</th>
        <th style="width: 1px;">&nbsp;</th>
        <th>Date</th>
        <th>Temp. (C)</th>
        <th>Summary</th>
    </Header>
    <Row>
        @if (context.Date != date)
        {
            <td><button type="button" class="btn btn-primary" @onclick="@(() => Select(context))">Edit</button></td>
            <td><button type="button" class="btn btn-danger" @onclick="@(() => Delete(context))">Delete</button></td>
            <td>@context.Date</td>
            <td>@context.TemperatureC</td>
            <td>@context.Summary</td>
        }
        else
        {
            <td><button type="button" class="btn btn-success" @onclick="Save">Save</button></td>
            <td><button type="button" class="btn btn-secondary" @onclick="Cancel">Cancel</button></td>
            <td><input type="date" id="date" class="form-control" @bind="@datetime" /></td>
            <td><input type="number" min="-20" max="55" id="tempC" class="form-control" @bind="@tempC" /></td>
            <td>
                <select id="summary" class="form-select" @bind="@summary">
                    @foreach (var summary in summaries)
                    {
                        <option value="@summary">@summary</option>
                    }
                </select>
            </td>
        }
    </Row>
</Pager>

<br />
<button type="button" class="btn btn-success" @onclick="Update">Update</button>

@code {
    public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Edit;

    private string[] summaries = { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };

    private List<WeatherForecast> forecasts;
    private DateOnly date;
    private DateTime datetime;
    private string tempC;
    private string summary;
    private int currentpage;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            await Load();
        }
        catch (Exception ex)
        {
            await logger.LogError(ex, "Error Loading Weather Forecasts {Error}", ex.Message);
            AddModuleMessage("Error Loading Weather Forecasts", MessageType.Error);
        }
    }

    private async Task Load()
    {
        forecasts = await WeatherForecastService.GetWeatherForecastsAsync(ModuleState.ModuleId);
    }

    private void Add()
    {
        Validate();
        currentpage = 1;
        forecasts.Insert(0, new WeatherForecast { Date = DateOnly.MinValue, TemperatureC = 0, Summary = "Chilly" });
        date = DateOnly.MinValue;
        datetime = DateTime.Now;
        tempC = "0";
        summary = "Chilly";
        StateHasChanged();
    }

    private void Select(WeatherForecast forecast)
    {
        Validate();
        date = forecast.Date;
        datetime = date.ToDateTime(TimeOnly.Parse("0:00"));
        tempC = forecast.TemperatureC.ToString();
        summary = forecast.Summary;
        StateHasChanged();
    }

    private void Delete(WeatherForecast forecast)
    {
        Validate();
        forecasts.Remove(forecast);
        StateHasChanged();
    }

    private void Save()
    {
        if (date == DateOnly.FromDateTime(datetime) || !forecasts.Any(item => item.Date == DateOnly.FromDateTime(datetime)))
        {
            var index = forecasts.FindIndex(item => item.Date == date);
            forecasts[index] = new WeatherForecast { Date = DateOnly.FromDateTime(datetime), TemperatureC = int.Parse(tempC), Summary = summary };
            forecasts = forecasts.OrderBy(item => item.Date).ToList();
            date = DateOnly.MinValue;
            StateHasChanged();
        }
        else
        {
            AddModuleMessage("Forecast Already Exists For Date", MessageType.Warning);
        }
    }

    private void Cancel()
    {
        Validate();
        date = DateOnly.MinValue;
        StateHasChanged();
    }

    private async Task Update()
    {
        try
        {
            Validate();
            await WeatherForecastService.UpdateWeatherForecastsAsync(ModuleState.ModuleId, forecasts);
            await Load();
            AddModuleMessage("Forecasts Updated", MessageType.Success);
        }
        catch (Exception ex)
        {
            await logger.LogError(ex, "Error Updating Weather Forecasts {Error}", ex.Message);
            AddModuleMessage("Error Updating Weather Forecasts", MessageType.Error);
        }
    }

    private void Validate()
    {
        var index = forecasts.FindIndex(item => item.Date == DateOnly.MinValue);
        if (index != -1)
        {
            forecasts.RemoveAt(index);
        }
    }

    private void OnPageChange(int page)
    {
        currentpage = page;
    }
}
    
Weather Tutorial

And lastly we need to enhance the Index.razor component so that authorized users will be able to click a button to access the new Edit component. We will use the ActionLink component to accomplish this. The ActionLink is integrated with the Oqtane security system to ensure that the link is only displayed to users who are authenticated and have Edit permissions for the module.

    
<ActionLink Action="Edit" />

@if (forecasts == null)
    

At this point we have migrated the Weather page from a standard Blazor application to Oqtane, and we have significantly enhanced its capabilities by leveraging a variety of different services and capabilities offered by the Oqtane framework. The module can be dynamically added to pages throughout the site, and we can even take advantage of Oqtane's multi-tenant capability to create additional sites in our installation where we can use the Weather module.

Hopefully this helps answer the question of how to migrate a standard Blazor application to Oqtane. It should also be noted that the scaffolded module we created from the Default Module Template has a lot more functionality than what was described in this tutorial. For example it contains classes for creating a database migration to create a new database table as well as a repository which can manage the data in that table. It also contains logic which would allow the module to be used in interactive render mode on WebAssembly, or even within a .NET MAUI mobile or desktop application.



Share Your Feedback...
Do You Want To Be Notified When Blogs Are Published?
RSS