When you buy this book you support this site! - Thank You for your support!

Nov 17

Written by: Michael Washington
11/17/2019 3:16 PM  RssIcon

image

In this article we will create a Database Driven module for Oqtane, the new web application framework that runs in Microsoft Blazor

 

image

Microsoft Blazor, a web framework for .NET Core that lets you build interactive web UIs using C# instead of JavaScript. Blazor apps are composed of reusable web UI components implemented using C#, HTML, and CSS. Both client and server code is written in C#, allowing you to share code and libraries.

Oqtane was created by Shaun Walker and is inspired by the DotNetNuke web application framework. Initially created as a proof of concept, Oqtane is a native Blazor application written from the ground up using modern .NET Core technology. It is a modular framework offering a fully dynamic page compositing model, multi-site support, designer friendly templates ( skins ), and extensibility via third party modules.

 

Getting Started

image

See the article Creating a Hello World Module For Blazor Oqtane for instructions on getting started with Microsoft’s Blazor and Oqtane.

 

image

For this article, we will create a To Do List module based on the Microsoft Build your first Blazor app tutorial.

 

Create the Data Layer: Add The Table

image

Open Oqtane in Visual Studio 2019  (or higher).

 

image

Open the SQL Server Object Explorer.

 

image

Open the Oqtane database.

 

image

Right-click on the Tables folder and select New Query.

 

image

Enter the following script and click the Execute button:

 

CREATE TABLE [dbo].[ToDoItem] (
    [ToDoItemId] INT            IDENTITY (1, 1) NOT NULL,
    [ModuleId]   INT            NOT NULL,
    [Title]    NVARCHAR (4000) NOT NULL,
    [IsDone]    bit NOT NULL,
    CONSTRAINT [PK_ToDoItem] PRIMARY KEY CLUSTERED ([ToDoItemId] ASC),
    CONSTRAINT [FK_ToDoItem_Module] FOREIGN KEY ([ModuleId]) REFERENCES [dbo].[Module] ([ModuleId]) ON DELETE CASCADE
);

 

image

Close the script window (no need to save), and refresh the Tables folder…

 

image

The ToDoItem table will display.

 

Create the Data Layer: Add The Repository Code

image

In Visual Studio, return to the Solution Explorer window.

We need to create a class that will be shared between the Client UI and server-side code that will manage the To Do items.

In the Shared project, add a class called ToDoItemInfo.cs with the following code:

 

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Oqtane.Shared.Modules.ToDoItem.Models
{
    [Table("ToDoItem")]
    public class ToDoItemInfo
    {
        [Key]
        public int ToDoItemId { get; set; }
        public int ModuleId { get; set; }
        public string Title { get; set; }
        public bool IsDone { get; set; }
    }
}

 

image

We now need to add the repository code that will allow the custom module to communicate with the database.

Right-click on the Modules folder in the Server project and create a folder named ToDoItem.

 

image

Add a folder named Repository, and in that folder, add the following files:

 

IToDoItemRepository.cs

 

using System.Collections.Generic;
using Oqtane.Shared.Modules.ToDoItem.Models;
namespace Oqtane.Server.Modules.ToDoItem.Repository
{
    public interface IToDoItemRepository
    {
        IEnumerable<ToDoItemInfo> GetToDoItems(int moduleid);
        void AddToDoItem(ToDoItemInfo ToDoItem);
        void UpdateToDoItem(ToDoItemInfo ToDoItem);
        ToDoItemInfo GetToDoItem(int ToDoItemId);
        void DeleteToDoItem(int ToDoItemId);
    }
}

 

ToDoItemContext.cs

 

using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Http;
using Oqtane.Repository;
using Oqtane.Modules;
using Oqtane.Shared.Modules.ToDoItem.Models;
namespace Oqtane.Server.Modules.ToDoItem.Repository
{
    public class ToDoItemContext : DBContextBase, IService
    {
        public virtual DbSet<ToDoItemInfo> ToDoItem { get; set; }
        public ToDoItemContext(
            ITenantResolver TenantResolver, 
            IHttpContextAccessor accessor) : base(TenantResolver, accessor)
        {
            // ContextBase handles multi-tenant database connections
        }
    }
}

 

ToDoItemRepository.cs

 

using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
using System.Linq;
using Oqtane.Modules;
using Oqtane.Shared.Modules.ToDoItem.Models;
namespace Oqtane.Server.Modules.ToDoItem.Repository
{
    public class ToDoItemRepository : IToDoItemRepository, IService
    {
        private readonly ToDoItemContext db;
        public ToDoItemRepository(ToDoItemContext context)
        {
            db = context;
        }
        public IEnumerable<ToDoItemInfo> GetToDoItems(int moduleid)
        {
            try
            {
                return db.ToDoItem.Where(x => x.ModuleId == moduleid).ToList();
            }
            catch
            {
                throw;
            }
        }
        public void AddToDoItem(ToDoItemInfo ToDoItem)
        {
            try
            {
                db.ToDoItem.Add(ToDoItem);
                db.SaveChanges();
            }
            catch
            {
                throw;
            }
        }
        public void UpdateToDoItem(ToDoItemInfo ToDoItem)
        {
            try
            {
                db.Entry(ToDoItem).State = EntityState.Modified;
                db.SaveChanges();
            }
            catch
            {
                throw;
            }
        }
        public ToDoItemInfo GetToDoItem(int ToDoItemId)
        {
            try
            {
                ToDoItemInfo ToDoItem = db.ToDoItem.Find(ToDoItemId);
                return ToDoItem;
            }
            catch
            {
                throw;
            }
        }
        public void DeleteToDoItem(int ToDoItemId)
        {
            try
            {
                ToDoItemInfo ToDoItem = db.ToDoItem.Find(ToDoItemId);
                db.ToDoItem.Remove(ToDoItem);
                db.SaveChanges();
            }
            catch
            {
                throw;
            }
        }
    }
}

 

A few things to note:

 

  • This code would always run server-side. Even when running Oqtane in client-mode.
  • This code would not directly communicate with the Client UI layer, the controller code (created next), will do that.

 

 

Create the Data Layer: Add The Controller

image

We will now create the controller that will communicate with the Client UI layer (that will be created in the next section).

Add a folder named Controllers, and under that folder add a file called ToDoItemController.cs using the following code:

 

using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Oqtane.Infrastructure;
using Oqtane.Shared;
using Oqtane.Shared.Modules.ToDoItem.Models;
using Oqtane.Server.Modules.ToDoItem.Repository;
namespace Oqtane.Server.Modules.ToDoItem.Controllers
{
    [Route("{site}/api/[controller]")]
    public class ToDoItemController : Controller
    {
        private IToDoItemRepository toDoItem;
        private readonly ILogManager logger;
        // EntityId is passed as a querystring parameter 
        // for authorization and used for validation
        // It should always match the moduleid
        // If not the call is ignored
        private int EntityId = -1;
        public ToDoItemController(
            IToDoItemRepository ToDoItem, 
            IHttpContextAccessor HttpContextAccessor, 
            ILogManager logger)
        {
            toDoItem = ToDoItem;
            this.logger = logger;
            if (HttpContextAccessor.
                HttpContext
                .Request
                .Query
                .ContainsKey("entityid"))
            {
                EntityId = 
                    int.Parse(
                        HttpContextAccessor
                        .HttpContext
                        .Request.Query["entityid"]
                        );
            }
        }
        // GET: api/<controller>
        [HttpGet]
        [Authorize(Policy = "ViewModule")]
        public IEnumerable<ToDoItemInfo> 
            Get(int moduleid)
        {
            try
            {
                IEnumerable<ToDoItemInfo> colToDoItemInfo = 
                    new List<ToDoItemInfo>();
                if (moduleid == EntityId)
                {
                    colToDoItemInfo = 
                        toDoItem.GetToDoItems(moduleid);
                }
                return colToDoItemInfo;
            }
            catch (Exception ex)
            {
                logger.Log(LogLevel.Error, this, 
                    LogFunction.Read, ex, 
                    "Get Error {Error}", ex.Message);
                throw;
            }
        }
        // GET api/<controller>/5
        [HttpGet("{id}")]
        [Authorize(Policy = "ViewModule")]
        public ToDoItemInfo 
            Get(int id, int moduleid)
        {
            try
            {
                ToDoItemInfo objToDoItemInfo = new ToDoItemInfo();
                if (moduleid == EntityId)
                {
                    objToDoItemInfo = toDoItem.GetToDoItem(id);
                }
                return objToDoItemInfo;
            }
            catch (Exception ex)
            {
                logger.Log(LogLevel.Error, this, 
                    LogFunction.Read, ex, 
                    "Get Error {Error}", ex.Message);
                throw;
            }
        }
        // POST api/<controller>
        [HttpPost]
        [Authorize(Policy = "EditModule")]
        public void 
            Post([FromBody] ToDoItemInfo ToDoItem)
        {
            try
            {
                if (ModelState.IsValid && 
                    ToDoItem.ModuleId == EntityId)
                {
                    toDoItem.AddToDoItem(ToDoItem);
                }
            }
            catch (Exception ex)
            {
                logger.Log(LogLevel.Error, this, 
                    LogFunction.Read, ex, 
                    "Get Error {Error}", ex.Message);
                throw;
            }
        }
        // PUT api/<controller>/5
        [HttpPut("{id}")]
        [Authorize(Policy = "EditModule")]
        public void 
            Put(int id, [FromBody] ToDoItemInfo ToDoItem)
        {
            try
            {
                if (ModelState.IsValid && 
                    ToDoItem.ModuleId == EntityId)
                {
                    toDoItem.UpdateToDoItem(ToDoItem);
                }
            }
            catch (Exception ex)
            {
                logger.Log(LogLevel.Error, this, 
                    LogFunction.Read, ex, 
                    "Get Error {Error}", ex.Message);
                throw;
            }
        }
        // DELETE api/<controller>/5
        [HttpDelete("{id}")]
        [Authorize(Policy = "EditModule")]
        public void Delete(int id, int moduleid)
        {
            try
            {
                if (moduleid == EntityId)
                {
                    toDoItem.DeleteToDoItem(id);
                }
            }
            catch (Exception ex)
            {
                logger.Log(LogLevel.Error, this, 
                    LogFunction.Read, ex, 
                    "Get Error {Error}", ex.Message);
                throw;
            }
        }
    }
}

 

A few things to note:

 

  • This code would always run server-side. Even when running Oqtane in client-mode.
  • This code will directly communicate with the Client UI layer
  • If Oqtane were running only in server-side mode, you would not need this controller. Code in the Client UI could call the Repository code (created earlier) directly.

 

Regarding “Security”:

The system can securely control access to call the Post method. However, the system does not inspect the payload of the actual content.

So let’s imagine that a user has Edit permission to ModuleId = 1 and they craft the Controller Url to use this entityid – however in the object itself, they specify ModuleId = 2.

The system will allow them to call the Post method and the repository will happily insert the new record for a Module to which they are not authorized. So this is where the content level security comes in to play within the Post method:

if (ModelState.IsValid && ToDoItem.ModuleId == EntityId)

 

It needs to verify that the ModuleId specified in the payload matches the entityid they were authorized to insert a record for.

 

Create The User Interface : Service Interface

image

In the Client project, add a folder in the Modules folder named ToDoItem.

Add a folder under that folder named Services.

Add the following files to that folder:

 

IToDoItemService.cs

 

using System.Collections.Generic;
using System.Threading.Tasks;
using Oqtane.Shared.Modules.ToDoItem.Models;
namespace Oqtane.Client.Modules.ToDoItem.Services
{
    public interface IToDoItemService
    {
        Task<List<ToDoItemInfo>> GetToDoItemsAsync(int ModuleId);
        Task AddToDoItemAsync(ToDoItemInfo ToDoItem);
        Task UpdateToDoItemAsync(ToDoItemInfo ToDoItem);
        Task DeleteToDoItemAsync(int ToDoItemId, int ModuleId);
    }
}

 

ToDoItemService.cs

 

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Net.Http;
using Microsoft.AspNetCore.Components;
using Oqtane.Services;
using Oqtane.Shared.Modules.ToDoItem.Models;
using Oqtane.Shared;
namespace Oqtane.Client.Modules.ToDoItem.Services
{
    public class ToDoItemService : ServiceBase, IToDoItemService
    {
        private readonly HttpClient http;
        private readonly SiteState sitestate;
        private readonly NavigationManager NavigationManager;
        public ToDoItemService(
            HttpClient http,
            SiteState sitestate,
            NavigationManager
            NavigationManager)
        {
            this.http = http;
            this.sitestate = sitestate;
            this.NavigationManager = NavigationManager;
        }
        private string apiurl
        {
            get
            {
                return CreateApiUrl(
                sitestate.Alias,
                NavigationManager.Uri,
                "ToDoItem");
            }
        }
        public async Task<List<ToDoItemInfo>>
            GetToDoItemsAsync(int ModuleId)
        {
            List<ToDoItemInfo> ToDoItems =
                await http.GetJsonAsync<List<ToDoItemInfo>>(
                    apiurl
                    + "?moduleid="
                    + ModuleId.ToString()
                    + "&entityid="
                    + ModuleId.ToString());
            return ToDoItems;
        }
        public async Task<ToDoItemInfo>
            GetToDoItemAsync(int ToDoItemId, int ModuleId)
        {
            ToDoItemInfo ToDoItem =
                await http.GetJsonAsync<ToDoItemInfo>(
                    apiurl
                    + "/"
                    + ToDoItemId.ToString()
                    + "?moduleid=" + ModuleId.ToString()
                    + "&entityid=" + ModuleId.ToString());
            return ToDoItem;
        }
        public async Task
            AddToDoItemAsync(ToDoItemInfo ToDoItem)
        {
            await http.PostJsonAsync(
                apiurl
                + "?entityid="
                + ToDoItem.ModuleId.ToString(),
                ToDoItem);
        }
        public async Task
            UpdateToDoItemAsync(ToDoItemInfo ToDoItem)
        {
            await http.PutJsonAsync(
                apiurl
                + "/"
                + ToDoItem.ToDoItemId.ToString()
                + "?entityid="
                + ToDoItem.ModuleId.ToString(),
                ToDoItem);
        }
        public async Task
            DeleteToDoItemAsync(int ToDoItemId, int ModuleId)
        {
            await http.DeleteAsync(
                apiurl
                + "/"
                + ToDoItemId.ToString()
                + "?moduleid="
                + ModuleId.ToString()
                + "&entityid="
                + ModuleId.ToString());
        }
    }
}

 

This code just allows us to keep the remaining code clean by handling all the calls to the data layer in a single place. 

 

Create The User Interface : The View

image

We will now create the first page of the custom module.

This will be the first code that will run when the module is invoked in Oqtane.

Create a file named Index.razor using the following code:

 

@using Oqtane.Client.Modules.ToDoItem.Services
@using Oqtane.Modules
@inherits ModuleBase
@using Oqtane.Shared.Modules.ToDoItem.Models;
@using System.Net.Http;
@inject NavigationManager NavigationManager
@inject HttpClient http
@inject SiteState sitestate
@inject ISettingService SettingService
    <Pager PageSize="@PageSize" 
           Format="Grid" 
           Items="@ToDoItems">
        <Row>
            <div class="col">
                @if (@context.IsDone)
                {
                    <span style="font-size:110%;">
                        <img src="images/checked.png" />&nbsp;
                        <del><b>@context.Title</b></del>
                    </span>
                }
                else
                {
                    <span style="font-size:110%;">
                        <img src="images/null.png" />&nbsp;
                        <b>@context.Title</b>
                    </span>
                }
            </div>
            <br /><br />
        </Row>
    </Pager>
<br />
<ActionLink Action="Add" 
            Security="SecurityAccessLevel.Edit" 
            Text="Edit" />
<br />
@code {
    List<ToDoItemInfo> ToDoItems = new List<ToDoItemInfo>();
    ToDoItemInfo ToDoItem = new ToDoItemInfo();
    string PageSize;
    protected override async Task OnParametersSetAsync()
    {
        try
        {
            Dictionary<string, string> settings = 
                await SettingService.GetModuleSettingsAsync(
                    ModuleState.ModuleId);
            PageSize = 
                Convert.ToString(
                    SettingService.GetSetting(settings, 
                    "PageSize", 
                    "5"));
            ToDoItemService ToDoItemService = 
                new ToDoItemService(http, 
                sitestate, 
                NavigationManager);
                ToDoItems = 
                    new List<ToDoItemInfo>();
                var result = 
                    await ToDoItemService.GetToDoItemsAsync(
                        ModuleState.ModuleId);
                ToDoItems = 
                    result.ToList();
        }
        catch (Exception ex)
        {
            await 
                logger.LogError(ex, 
                "Error Loading ToDoItems {Error}", 
                ex.Message);
            ModuleInstance.AddModuleMessage(
                "Error Loading Blogs", MessageType.Error
                );
        }
    }
    private async Task Delete(int id)
    {
        try
        {
            ToDoItemService ToDoItemService = 
                new ToDoItemService(http, 
                sitestate, 
                NavigationManager);
            await ToDoItemService.DeleteToDoItemAsync(
                id, ModuleState.ModuleId);
            await logger.LogInformation("Deleted {Id}", id);
            NavigationManager.NavigateTo(NavigateUrl());
        }
        catch (Exception ex)
        {
            await logger.LogError(
                ex, 
                "Error Deleting {Id} {Error}", 
                id, ex.Message);
            ModuleInstance.AddModuleMessage(
                "Error Deleting", 
                MessageType.Error);
        }
    }
}

 

Note that:

 

<ActionLink Action="Add" 
            Security="SecurityAccessLevel.Edit" 
            Text="Edit" />

 

in the code above means that the link will only show for users who have Edit permission to the module.

 

Create The User Interface : The Edit Interface

image

Oqtane allows the administrator to designate certain pages to be accessible only to certain roles (usually the administrator roles). We will create a separate page that will allow the To Do items to be edited.

Create a page called Edit.razor using the following code:

 

@using System.Net.Http;
@using Oqtane.Modules
@using Oqtane.Client.Modules.ToDoItem.Services
@using Oqtane.Shared.Modules.ToDoItem.Models
@inherits ModuleBase
@inject NavigationManager NavigationManager
@inject HttpClient http
@inject SiteState sitestate
<ul style="list-style-type:none;">
    @foreach (var todo in ToDoItems)
    {
        <li>
            <input type="checkbox"
                   @bind="todo.IsDone"
                   @onclick="@(() => UpdateIsDone(todo))" />
            @todo.Title
            <button type="button" style="border:none"
                    @onclick="@(() => DeleteToDo(todo))">
                <img src="images/unchecked.png" />
            </button>
        </li>
    }
</ul>
<input placeholder="Enter a new todo" @bind="newTodo" />
<button class="btn btn-success" @onclick="AddTodo">Add To Do</button>
@code {
    // the SecurityAccessLevelEnum.Edit is what makes this configurable
    // by the Oqtane administrator as an "Edit" control
    public override SecurityAccessLevel
        SecurityAccessLevel { get { return SecurityAccessLevel.Edit; } }
    public override string Actions { get { return "Add,Edit"; } }
    // Oqtane uses this to display the tile of the custom module
    public override string Title
    {
        get { return "Edit To Do Items"; }
    }
    List<ToDoItemInfo> ToDoItems = new List<ToDoItemInfo>();
    private string newTodo;
    protected override async Task OnInitializedAsync()
    {
        try
        {
            await RefreshList();
        }
        catch (Exception ex)
        {
            await
                logger.LogError(
                    ex, "Error Loading ToDoItems {Error}",
                    ex.Message);
            ModuleInstance.AddModuleMessage(
                "Error Loading To Do Items", MessageType.Error
                );
        }
    }
    private async Task RefreshList()
    {
        ToDoItemService ToDoItemService =
                 new ToDoItemService(http, sitestate, NavigationManager);
        // This module can be placed in Oqtane multiple times
        // Each time it will have a different ModuleState.ModuleId
        ToDoItems =
            await ToDoItemService.GetToDoItemsAsync(
                ModuleState.ModuleId);
        ToDoItems =
            new List<ToDoItemInfo>();
        var result =
            await ToDoItemService.GetToDoItemsAsync(ModuleState.ModuleId);
        ToDoItems =
            result.ToList();
    }
    private async Task AddTodo()
    {
        if (!string.IsNullOrWhiteSpace(newTodo))
        {
            ToDoItemService ToDoItemservice =
                new ToDoItemService(http, sitestate, NavigationManager);
            ToDoItemInfo AddToDo = new ToDoItemInfo();
            AddToDo.ModuleId = ModuleState.ModuleId;
            AddToDo.Title = newTodo;
            AddToDo.IsDone = false;
            ToDoItems.Add(AddToDo);
            newTodo = string.Empty;
            await ToDoItemservice.AddToDoItemAsync(AddToDo);
            await RefreshList();
        }
    }
    private async Task DeleteToDo(ToDoItemInfo ToDoToDelete)
    {
        ToDoItemService ToDoItemservice =
            new ToDoItemService(http, sitestate, NavigationManager);
        await ToDoItemservice.DeleteToDoItemAsync(
            ToDoToDelete.ToDoItemId, ModuleState.ModuleId);
        await RefreshList();
    }
    private async Task UpdateIsDone(ToDoItemInfo ToDoToUpdate)
    {
        ToDoToUpdate.IsDone = !ToDoToUpdate.IsDone;
        ToDoItemService ToDoItemservice =
            new ToDoItemService(http, sitestate, NavigationManager);
        await ToDoItemservice.UpdateToDoItemAsync(ToDoToUpdate);
    }
}

 

Note that line:

 

public override string Actions { get { return "Add,Edit"; } }

 

Tells the Oqtane framework that this control will respond to Edit and Add links.

In this example we only implemented an Edit link.

 

The Settings Page

image

Oqtane allows each module instance to display a custom setting user interface to gather and display settings values.  We will create a control that will allow the number of items to display to be edited.

Create a page called Setting.razor using the following code:

 

@inherits ModuleBase
@inject NavigationManager NavigationManager
@inject HttpClient http
@inject SiteState sitestate
@inject ISettingService SettingService
<table class="form-group">
    <tr>
        <td>
            <label for="Setting" 
                   class="control-label">Page Size: </label>
        </td>
        <td>
            <input type="text" 
                   class="form-control" @bind="PageSize" />
        </td>
    </tr>
</table>
@code {
    string PageSize;
    protected override async Task OnInitializedAsync()
    {
        try
        {
            Dictionary<string, string>
            settings = 
            await SettingService.GetModuleSettingsAsync(
                ModuleState.ModuleId);
            PageSize = 
                SettingService.GetSetting(
                    settings, 
                    "PageSize", 
                    "5");
        }
        catch (Exception ex)
        {
            ModuleInstance.AddModuleMessage(
                ex.Message, 
                MessageType.Error);
        }
    }
    public async Task UpdateSettings()
    {
        try
        {
            Dictionary<string, string>
            settings = 
            await SettingService.GetModuleSettingsAsync(
                ModuleState.ModuleId);
            SettingService.SetSetting(
                settings, 
                "PageSize", 
                PageSize);
            await SettingService.UpdateModuleSettingsAsync(
                settings, 
                ModuleState.ModuleId);
        }
        catch (Exception ex)
        {
            ModuleInstance.AddModuleMessage(
                ex.Message, 
                MessageType.Error);
        }
    }
}

 

Invoke The Module

image

Hit F5 to run Oqtane.

 

image

The site will display.

Click Login.

 

image

Login as host.

 

image

Go to a page.

 

image

Click the Admin link on the hamburger menu.

 

image

The ToDoItem module will show up in the Module dropdown list.

Use the settings screen to add the module to the page.

 

image

The module will show on the page.

 

image

Click the pencil icon to go into edit mode.

 

image

The page and the module will now be in edit mode.

 

image

You can select the dropdown next to the module title to go to Settings.

 

image

This will allow you to set the security for this instance of the module.

The UI for the Settings.razor page will display at the bottom and allow you to set the number of items to display.

Click the Save button to save any changes.

 

image

To add items, click the Edit button (this will only show for users in the security role that have Edit permission as configured in the previous step).

 

image

This will allow you to add and edit items.

 

image

Note that you can place multiple instances of the module throughout the site, with different security configurations, and their own list.

 

Links

Creating a Hello World Module For Blazor Oqtane

www.oqtane.org

Blazor.net

Get started with Blazor

Build your first Blazor app

Announcing Oqtane... a Modular Application Framework for Blazor!

Oqtane (GitHub)

Oqtane Custom Module Sample (GitHub)

Tags: Blazor , Oqtane
Categories:

Your name:
Gravatar Preview
Your email:
(Optional) Email used only to show Gravatar.
Your website:
Title:
Comment:
Security Code
CAPTCHA image
Enter the code shown above in the box below
Add Comment   Cancel 
Microsoft Visual Studio is a registered trademark of Microsoft Corporation / LightSwitch is a registered trademark of Microsoft Corporation