Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / Blazor

BlazorForms Low-Code Open-Source Framework. Part 2: CrmLight Project

4.81/5 (10 votes)
6 Feb 2023CPOL8 min read 21.3K  
Developing applications based on Flows, Forms, and Rules using type-safe advantages of C#
This article discusses the CrmLightDemoApp project that was created to demonstrate how to realize more complex scenarios than those presented in the basic seed project.

All BlazorForms articles in this series:

We deployed a working solution here:

https://crmlight.platz.app

Please remember it doesn't have a database and it keeps all data in memory, shared for all users, and after some time of inactivity, the container shuts down losing all data.

We deployed to a Linux docker container with minimum CPU and memory requirements, but it works very fast. 

Introduction

This post continues the series of posts about the BlazorForms framework developed by PRO CODERS PTY LTD and shared as an open-source project with MIT license.

In the previous post, “BlazorForms low-code open-source framework introduction and seed projects”, I presented this framework to simplify Blazor UI development and to allow the creation of simple and maintainable C# code.

The main idea of the framework is to provide a pattern that isolates logic from UI and forces developers to keep logic in Flows and Rules. Forms simply contain bindings between Model and UI controls.

There is no direct UI manipulation needed unless you want it to be highly customized. This means that the logic in Flows and Rules is not UI-dependant and is 100% unit-testable.

To minimize the initial effort, we have created the below seed projects, which are available on GitHub, and I copied CrmLight project version 0.7.0 to my Blog repository.

Download this blog post code from GitHub:

BlazorForms project on GitHub:

CrmLight Project

CrmLightDemoApp project was created to demonstrate how to realize more complex scenarios than those presented in the basic seed project. CrmLight Flows consume Repositories that have full implementation of CRUD operations with some extensions.

Data and Repositories

The application works with several entities and relationships:

  • Company
  • Person
  • PersonCompanyLink
  • PersonCompanyLinkType

The relationships between them can be shown in the diagram:

Image 1

Picture 1

To implement data access, we used a classic Repository pattern, which means that for each entity, we have a specialized Repository. However, there is no point to implement the same CRUD operations many times, so we have used generics.

If you look at the solution explorer, you will see the simplified Onion architecture folder structure:

Image 2

Picture 2

Where IRepository.cs defines generic interface for all repositories:

C#
namespace CrmLightDemoApp.Onion.Domain.Repositories
{
    public interface IRepository<T>
        where T : class
    {
        Task<List<T>> GetAllAsync();
        IQueryable<T> GetAllQuery();
        Task<List<T>> RunQueryAsync(IQueryable<T> query);
        Task<T> GetByIdAsync(int id);
        Task<int> CreateAsync(T data);
        Task UpdateAsync(T data);
        Task DeleteAsync(int id);
        Task SoftDeleteAsync(int id);
        Task<List<T>> GetListByIdsAsync(IEnumerable<int> ids);
    }
}

and LocalCacheRepository.cs implements this interface:

C#
using BlazorForms.Shared;
using CrmLightDemoApp.Onion.Domain;
using CrmLightDemoApp.Onion.Domain.Repositories;

namespace CrmLightDemoApp.Onion.Infrastructure
{
    // this is repository emulator that stores all data in memory
    // it stores and retrieves object copies, like a real database
    public class LocalCacheRepository<T> : IRepository<T>
        where T : class, IEntity
    {
        protected int _id = 0;
        protected readonly List<T> _localCache = new List<T>();

        public async Task<int> CreateAsync(T data)
        {
            _id++;
            data.Id = _id;
            _localCache.Add(data.GetCopy());
            return _id;
        }

        public async Task DeleteAsync(int id)
        {
            _localCache.Remove(_localCache.Single(x => x.Id == id));
        }

        public async Task<T> GetByIdAsync(int id)
        {
            return _localCache.Single(x => x.Id == id).GetCopy();
        }

        public async Task<List<T>> GetAllAsync()
        {
            return _localCache.Where
                   (x => !x.Deleted).Select(x => x.GetCopy()).ToList();
        }

        public async Task UpdateAsync(T data)
        {
            await DeleteAsync(data.Id);
            _localCache.Add(data.GetCopy());
        }

        public async Task SoftDeleteAsync(int id)
        {
            _localCache.Single(x => x.Id == id).Deleted = true;
        }

        public async Task<List<T>> GetListByIdsAsync(IEnumerable<int> ids)
        {
            return _localCache.Where(x => ids.Contains(x.Id)).Select
                                    (x => x.GetCopy()).ToList();
        }

        public IQueryable<T> GetAllQuery()
        {
            return _localCache.AsQueryable();
        }

        public async Task<List<T>> RunQueryAsync(IQueryable<T> query)
        {
            return query.ToList();
        }
    }
}

As you can see, we don’t use any database in the project, keeping all data in a memory emulator. This should simplify the demo running experience, whilst also ensuring the developed Repositories can be useful for unit-testing.

To simplify code, we used GetCopy extension method from BlazorForms.Shared that uses reflection to create a new instance and copy all public properties to it.
Specialized repositories pre-populate some data which you can see when you run the application:

C#
using CrmLightDemoApp.Onion.Domain;
using CrmLightDemoApp.Onion.Domain.Repositories;

namespace CrmLightDemoApp.Onion.Infrastructure
{
    public class CompanyRepository : LocalCacheRepository<Company>, ICompanyRepository
    {
        public CompanyRepository()
        {
            // pre fill some data
            _localCache.Add(new Company { Id = 1, Name = "Mizeratti Pty Ltd", 
             RegistrationNumber = "99899632221", 
             EstablishedDate = new DateTime(1908, 1, 17) });
            _localCache.Add(new Company { Id = 2, Name = "Alpha Pajero", 
             RegistrationNumber = "89963222172", 
             EstablishedDate = new DateTime(1956, 5, 14) });
            _localCache.Add(new Company { Id = 3, Name = "Zeppelin Ltd Inc", 
             RegistrationNumber = "63222172899", 
             EstablishedDate = new DateTime(2019, 11, 4) });
            _localCache.Add(new Company { Id = 4, Name = "Perpetuum Automotives Inc", 
             RegistrationNumber = "22217289963", 
             EstablishedDate = new DateTime(2010, 1, 7) });
            _id = 10;
        }
    }
}

PersonCompanyRepository.cs has methods to join several entities and return PersonCompanyLinkDetails composition objects:

C#
public async Task<List<PersonCompanyLinkDetails>> GetByCompanyIdAsync(int companyId)
{
    var list = _localCache.Where(x => !x.Deleted && 
               x.CompanyId == companyId).Select(x =>
    {
        var item = new PersonCompanyLinkDetails();
        x.ReflectionCopyTo(item);
        return item;
    }).ToList();

    var company = await _companyRepository.GetByIdAsync(companyId);
    var personIds = list.Select(x => x.PersonId).Distinct().ToList();
    var persons = (await _personRepository.GetListByIdsAsync
                  (personIds)).ToDictionary(x => x.Id, x => x);
    var linkIds = list.Select(x => x.LinkTypeId).Distinct().ToList();
    var links = (await _personCompanyLinkTypeRepository.
                 GetListByIdsAsync(linkIds)).ToDictionary(x => x.Id, x => x);

    foreach (var item in list)
    {
        item.LinkTypeName = links[item.LinkTypeId].Name;
        item.PersonFullName = $"{persons[item.PersonId].FirstName} 
                              {persons[item.PersonId].LastName}";
        item.PersonFirstName = persons[item.PersonId].FirstName;
        item.PersonLastName = persons[item.PersonId].LastName;
        item.CompanyName = company.Name;
    }

    return list;
} 

Business Logic

Services folder contains BlazorForms related code – the application business logic:

Image 3

Picture 3

Flow Model

Before we start looking at Flows, I would like to mention that we don’t use domain entities as Models, instead, we use business model classes that may have the same properties as domain entities. Doing that, we can extend Flow Model with extra properties that can be useful for Flow processing logic. For example, CompanyModel inherits all properties from Company entity whilst also having special properties that we are going to use in Flow logic:

C#
public class CompanyModel : Company, IFlowModel
{
    public virtual List<PersonCompanyLinkDetailsModel> 
    PersonCompanyLinks { get; set; } = new List<PersonCompanyLinkDetailsModel>();
    public virtual List<PersonCompanyLinkDetailsModel> 
    PersonCompanyLinksDeleted { get; set; } = new List<PersonCompanyLinkDetailsModel>();
    public virtual List<PersonCompanyLinkType> AllLinkTypes { get; set; }
    public virtual List<PersonModel> AllPersons { get; set; }
}

List Flow

List Flow is a simplified Flow to present a list of records in a UI table form. It doesn’t have Define body but it has LoadDataAsync method to retrieve records data:

C#
public class CompanyListFlow : ListFlowBase<CompanyListModel, FormCompanyList>
{
    private readonly ICompanyRepository _companyRepository;

    public CompanyListFlow(ICompanyRepository companyRepository) 
    {
        _companyRepository = companyRepository;
    }

    public override async Task<CompanyListModel> 
           LoadDataAsync(QueryOptions queryOptions)
    {
        var q = _companyRepository.GetAllQuery();

        if (!string.IsNullOrWhiteSpace(queryOptions.SearchString))
        {
            q = q.Where(x => x.Name.Contains
                (queryOptions.SearchString, StringComparison.OrdinalIgnoreCase) 
                || (x.RegistrationNumber != null && 
                x.RegistrationNumber.Contains(queryOptions.SearchString, 
                StringComparison.OrdinalIgnoreCase)) );
        }

        if (queryOptions.AllowSort && !string.IsNullOrWhiteSpace
        (queryOptions.SortColumn) && queryOptions.SortDirection != SortDirection.None)
        {
            q = q.QueryOrderByDirection(queryOptions.SortDirection, 
                                        queryOptions.SortColumn);
        }
                
        var list = (await _companyRepository.RunQueryAsync(q)).Select(x =>
        {
            var item = new CompanyModel();
            x.ReflectionCopyTo(item);
            return item;
        }).ToList();

        var result = new CompanyListModel { Data = list };
        return result;
    }
}

As you can see, CompanyListFlow receives ICompanyRepository in the constructor via dependency injection and uses it to retrieve data.

QueryOptions parameter may contain Search pattern and/or Sorting information that we use to assemble query adding Where and OrderBy clauses – thanks to the flexibility of our Repository having GetAllQuery and RunQueryAsync methods.

After running the query, we iterate through the returned records and use extension method ReflectionCopyTo to copy all properties of returned Company entity to CompanyModel business object inherited from Company (BTW, you can use AutoMapper for that, but personally I think its syntax is over-complicated).

List Form

The last part of defining Company UI table is List Form which defines columns and navigation:

C#
public class FormCompanyList : FormListBase<CompanyListModel>
{
    protected override void Define(FormListBuilder<CompanyListModel> builder)
    {
        builder.List(p => p.Data, e =>
        {
            e.DisplayName = "Companies";

            e.Property(p => p.Id).IsPrimaryKey();
            e.Property(p => p.Name);
            e.Property(p => p.RegistrationNumber).Label("Reg. No.");
            e.Property(p => p.EstablishedDate).Label("Established date").
                       Format("dd/MM/yyyy");

            e.ContextButton("Details", "company-edit/{0}");
            e.NavigationButton("Add", "company-edit/0");
        });
    }
}

IsPrimaryKey() marks a column that contains the record primary key that will be supplied as a parameter for ContextButton navigation link format string “company-edit/{0}”.

We also provide column labels and date format for a DateTime column.

To render the form, we added FlowListForm control to CompanyList.razor in Pages folder:

Razor
@page "/company-list"

<FlowListForm FlowType="@typeof(CrmLightDemoApp.Onion.
 Services.Flow.CompanyListFlow).FullName" Options="GlobalSettings.ListFormOptions" />

@code {
}

You can run the application and search and sort Companies:

Image 4

Picture 4

If you click on the row, you will be navigated to Company edit page supplying the record primary key as a parameter, but if you click Add button instead, the Company edit page will get zero as the primary key parameter, which means – add a new record.

CompanyEdit.razor accepts parameter Pk and supplies it to FlowEditForm:

Razor
@page "/company-edit/{pk}"

<FlowEditForm FlowName="@typeof(CrmLightDemoApp.Onion.Services.
                        Flow.CompanyEditFlow).FullName" Pk="@Pk"
              Options="GlobalSettings.EditFormOptions" 
              NavigationSuccess="/company-list" />

@code {
    [Parameter]
    public string Pk { get; set; }
}

Edit Flow

CompanyEditFlow class defines two main cases – for non-zero ItemKey (supplied Pk) LoadData method should be executed and FormCompanyView should be shown to the user. FormCompanyView has Delete button and if it is pressed, then DeleteData method will be executed.

The second case – zero ItemKey or Edit button was pressed on FormCompanyView – in this case, the method LoadRelatedData will be executed and FormCompanyEdit will be shown to the user:

C#
public override void Define()
{
    this
        .If(() => _flowContext.Params.ItemKeyAboveZero)
            .Begin(LoadData)
            .NextForm(typeof(FormCompanyView))
        .EndIf()
        .If(() => _flowContext.ExecutionResult.FormLastAction == 
                                               ModelBinding.DeleteButtonBinding)
            .Next(DeleteData)
        .Else()
            .If(() => _flowContext.ExecutionResult.FormLastAction == 
            ModelBinding.SubmitButtonBinding || !_flowContext.Params.ItemKeyAboveZero)
                .Next(LoadRelatedData)
                .NextForm(typeof(FormCompanyEdit))
                .Next(SaveData)
            .EndIf()
        .EndIf()
        .End();
}

LoadData method populates Flow Model by Company details including PersonCompanyLinks – the references between Company and Person entities:

C#
public async Task LoadData()
{
    if (_flowContext.Params.ItemKeyAboveZero)
    {
        var item = await _companyRepository.GetByIdAsync(_flowContext.Params.ItemKey);
        // item and Model have different types - we use reflection 
        // to copy similar properties
        item.ReflectionCopyTo(Model);

        Model.PersonCompanyLinks = 
              (await _personCompanyRepository.GetByCompanyIdAsync(Model.Id))
            .Select(x =>
            {
                var item = new PersonCompanyLinkDetailsModel();
                x.ReflectionCopyTo(item);
                return item;
            }).ToList();
    }
}

DeleteData method simply deletes Company, using repository method SoftDeleteAsync, which changes entity Deleted flag to true.

LoadRelatedData method populates AllLinkTypes and AllPersons collections, which we will use for Dropdown and DropdownSearch controls.

SaveData method is executed if user presses Submit button on FormCompanyEdit and it updates Company record if it has ID above zero, or otherwise inserts it if ID was zero (add new Company case). This method also iterates through PersonCompanyLinksDeleted and PersonCompanyLinks collections and deletes records deleted by the user and inserts and updates records added and changed by the user:

C#
public async Task SaveData()
{
    if (_flowContext.Params.ItemKeyAboveZero)
    {
        await _companyRepository.UpdateAsync(Model);
    }
    else
    {
        Model.Id = await _companyRepository.CreateAsync(Model);
    }

    foreach (var item in Model.PersonCompanyLinksDeleted)
    {
        if (item.Id != 0)
        {
            await _personCompanyRepository.SoftDeleteAsync(item.Id);
        }
    }

    foreach (var item in Model.PersonCompanyLinks)
    {
        if (item.Id == 0)
        {
            item.CompanyId = Model.Id;
            await _personCompanyRepository.CreateAsync(item);
        }
        else if (item.Changed)
        {
            await _personCompanyRepository.UpdateAsync(item);
        }
    }
}

Edit Form

FormCompanyView defines Company read-only representation that shows all Company properties and PersonCompanyLinks in a table format. I have also added a confirmation message for Delete button:

C#
public class FormCompanyView : FormEditBase<CompanyModel>
{
    protected override void Define(FormEntityTypeBuilder<CompanyModel> f)
    {
        f.DisplayName = "Company View";

        f.Property(p => p.Name).Label("Name").IsReadOnly();
        f.Property(p => p.RegistrationNumber).Label("Reg. No.").IsReadOnly();
        f.Property(p => p.EstablishedDate).Label("Established date").IsReadOnly();

        f.Table(p => p.PersonCompanyLinks, e => 
        {
            e.DisplayName = "Associations";
            e.Property(p => p.LinkTypeName).Label("Type");
            e.Property(p => p.PersonFullName).Label("Person");
        });

        f.Button(ButtonActionTypes.Close, "Close");

        f.Button(ButtonActionTypes.Delete, "Delete")
            .Confirm(ConfirmType.Continue, "Delete this Company?", ConfirmButtons.YesNo);

        f.Button(ButtonActionTypes.Submit, "Edit");
    }
}

Image 5

Picture 5

FormCompanyEdit contains input controls for editing Company properties and PersonCompanyLinks that are defined in the Repeater control that presents an editable grid, in which records can be added, changed, or deleted.

C#
public class FormCompanyEdit : FormEditBase<CompanyModel>
{
    protected override void Define(FormEntityTypeBuilder<CompanyModel> f)
    {
        f.DisplayName = "Company Edit";
        f.Confirm(ConfirmType.ChangesWillBeLost,
        "If you leave before saving, your changes will be lost.",
         ConfirmButtons.OkCancel);

        f.Property(p => p.Name).Label("Name").IsRequired();
        f.Property(p => p.RegistrationNumber).Label("Reg. No.").IsRequired();
        f.Property(p => p.EstablishedDate).Label("Established date").IsRequired();

        f.Repeater(p => p.PersonCompanyLinks, e =>
        {
            e.DisplayName = "Associations";
            e.Property(p => p.Id).IsReadOnly().Rule(typeof
            (FormCompanyEdit_ItemDeletingRule), FormRuleTriggers.ItemDeleting);

            e.PropertyRoot(p => p.LinkTypeId).Dropdown
            (p => p.AllLinkTypes, m => m.Id,
             m => m.Name).IsRequired().Label("Type")
                .Rule(typeof(FormCompanyEdit_ItemChangedRule),
                 FormRuleTriggers.ItemChanged);

            e.PropertyRoot(p => p.PersonId).DropdownSearch
            (e => e.AllPersons, m => m.Id, m =>
                  m.FullName).IsRequired().Label("Person")
                .Rule(typeof(FormCompanyEdit_ItemChangedRule),
                             FormRuleTriggers.ItemChanged);
        }).Confirm(ConfirmType.DeleteItem, "Delete this association?",
                                            ConfirmButtons.YesNo);

        f.Button(ButtonActionTypes.Cancel, "Cancel");
        f.Button(ButtonActionTypes.Submit, "Save");
    }
}

Confirmation message is defined for leaving form without saving data; and another message for deleting PersonCompanyLinks record in the repeater.

PropertyRoot() function is the same as Property() but can be used inside Repeater when you need to refer to collections in root Model class.

The form also has two Rules.

FormCompanyEdit_ItemDeletingRule is triggered when the user deletes Repeater record, this Rule stores deleted record in a special Model collection, which we will use in SaveData method:

C#
public class FormCompanyEdit_ItemDeletingRule : FlowRuleBase<CompanyModel>
{
    public override string RuleCode => "CMP-1";

    public override void Execute(CompanyModel model)
    {
        // preserve all deleted items
        model.PersonCompanyLinksDeleted.Add
              (model.PersonCompanyLinks[RunParams.RowIndex]);
    }
}

FormCompanyEdit_ItemChangedRule is triggered when LinkType or Person property is changed by the user in Repeater and this rule updates Changed flag. This will also will be used in Flow SaveData method:

C#
public class FormCompanyEdit_ItemChangedRule : FlowRuleBase<CompanyModel>
{
    public override string RuleCode => "CMP-2";

    public override void Execute(CompanyModel model)
    {
        model.PersonCompanyLinks[RunParams.RowIndex].Changed = true;
    }
}

When the users click Edit button, they will see:

Image 6

Picture 6

I should mention once again that Forms don’t contain any business logic, they only define how Model is bound to Controls and Rules. Forms also cannot load or save any data. When you need to save/load data, you should use Flows. When you need to do complex validation or mark records that changed/reload some data, you should use Rules. Follow this advice and your code will be understandable and maintainable without any problems.

Person and PersonCompanyType Flows and Forms follow the same approach and you can see their code in the downloaded solution from GitHub.

Add SQL Database

To finish this post, I would like to substitute LocalCacheRepository with SqlRepository that stores data in SQL Database. The result solution I added to folder CrmLightDemoApp.Sql\.

To start using SQL, I added Packages Microsoft.EntityFrameworkCore.Tools and Microsoft.EntityFrameworkCore.SqlServer, then I added CrmContext.cs to Onion\Infrastructure\ folder:

C#
using CrmLightDemoApp.Onion.Domain;
using Microsoft.EntityFrameworkCore;

namespace CrmLightDemoApp.Onion.Infrastructure
{
    public class CrmContext : DbContext
    {
        public DbSet<Company> Company { get; set; }
        public DbSet<Person> Person { get; set; }
        public DbSet<PersonCompanyLink> PersonCompanyLink { get; set; }
        public DbSet<PersonCompanyLinkType> PersonCompanyLinkType { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder options)
            => options.UseSqlServer("Server = 
               (localdb)\\mssqllocaldb; Database=CrmLightDb1;
               Trusted_Connection=True;MultipleActiveResultSets=true");
    }
}

This is SQL Express mssqllocaldb and it should work on any Windows machine, but if you want you can specify your real SQL Server database in the connection string.

Then I needed to modify my Company, Person and PersonCompanyLinkType entities to include references to PersonCompanyLink table, which is required for EF Model-First approach, where database schema is generated from your entities.

Then I created an EF migration in Package Manager Console running the command:

PowerShell
Add-Migration InitialCreate

Next, you must run the below command on your Package Manager Console if you want to run the application:

PowerShell
Update-Database

This command will create tables and relationships in the target database.

Changing Repository

SqlRepository.cs was created instead of LocalCacheRepository. It consumes CrmContext to execute queries in the SQL database:

C#
using CrmLightDemoApp.Onion.Domain;
using CrmLightDemoApp.Onion.Domain.Repositories;
using Microsoft.EntityFrameworkCore;

namespace CrmLightDemoApp.Onion.Infrastructure
{
    public class SqlRepository<T> : IRepository<T>
        where T : class, IEntity, new()
    {
        public async Task<int> CreateAsync(T data)
        {
            using var db = new CrmContext();
            db.Set<T>().Add(data);
            await db.SaveChangesAsync();
            return data.Id;
        }

        public async Task DeleteAsync(int id)
        {
            using var db = new CrmContext();
            var table = db.Set<T>();
            var entity = new T { Id = id };
            table.Attach(entity);
            table.Remove(entity);
            await db.SaveChangesAsync();
        }

        public async Task<T> GetByIdAsync(int id)
        {
            using var db = new CrmContext();
            return await db.Set<T>().SingleAsync(x => x.Id == id);
        }

        public async Task<List<T>> GetAllAsync()
        {
            using var db = new CrmContext();
            return await db.Set<T>().Where(x => !x.Deleted).ToListAsync();
        }

        public async Task UpdateAsync(T data)
        {
            using var db = new CrmContext();
            db.Set<T>().Update(data);
            await db.SaveChangesAsync();
        }

        public async Task SoftDeleteAsync(int id)
        {
            using var db = new CrmContext();
            var record = await db.Set<T>().SingleAsync(x => x.Id == id);
            record.Deleted = true;
            await db.SaveChangesAsync();
        }

        public async Task<List<T>> GetListByIdsAsync(IEnumerable<int> ids)
        {
            using var db = new CrmContext();
            return await db.Set<T>().Where(x => ids.Contains(x.Id)).ToListAsync();
        }

        public ContextQuery<T> GetContextQuery()
        {
            var db = new CrmContext();
            return new ContextQuery<T>(db, db.Set<T>().Where(x => !x.Deleted));
        }

        public async Task<List<T>> RunContextQueryAsync(ContextQuery<T> query)
        {
            return await query.Query.ToListAsync();
        }
    }
}

Unfortunately, I needed to change IRepository<> interface because my initial design didn’t allow assembling Search and Sorting query outside of the Repository, and now GetContextQuery and RunContextQueryAsync methods are used and they work with disposable class ContextQuery<>.

Now if you run the application, the initial database will be empty and you will need to populate Person, Company, and PersonCompanyLink tables using UI.

Summary

In this post, I presented CrmLight seed project from BlazorForms open-source framework. It shows a straightforward approach on how to connect together database Repositories, application business logic, and user interface. In the end, I converted the solution from using memory mock Repositories to a real SQL database, so that the resulting solution can be a good start for some real projects.

In my future posts, I will focus on more complex scenarios, and other types of Flows and will present more use cases that are possible to implement using BlazorForms.

You can find the full solution code on my GitHub, folder Story-09-BlazorForms-CrmLight:

Thank you for reading and remember that you can always reach out to us if you would like any help with your implementations.

https://procoders.com.au/contact-us/

History

  • 12th January, 2023: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)