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

Developing applications based of Flows, Forms and Rules using type safe advantages of C#

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 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 customised. This means that the logic in Flows and Rules is not UI-depended and is 100% unit-testable.

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

Download this blog post code from GitHub –

https://github.com/euklad/BlogCode/tree/main/Story-09-BlazorForms-CrmLight

BlazorForms project on GitHub –

https://github.com/ProCodersPtyLtd/BlazorForms

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. 

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 on diagram:

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:

Where IRepository.cs defines generic interface for all repositories:

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:

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.

GetCopy extension method from BlazorForms.Shared uses reflection to copy all public properties to simplify code.
Specialized repositories pre populate some data which you can see when you run the application:

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:

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:

solution explorer

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 which 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:

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 method LoadDataAsync to retrieve records data:

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 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 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 it’s syntax is over complicated).

List Form

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

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 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:

@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:

CrmLight

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:

@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.cs 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:

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:

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:

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.cs 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:

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");

    }
}
CRM Light company view

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

Confirmation messages defined for leaving form without saving data and for deleting PersonCompanyLinks record are also 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 special Model collection, which we will use in SaveData method:

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:

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 user clicks Edit button they will see:

CRM Light company edit

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 download solution from GitHub.

Add SQL Database

To finish this post, I would like to substitute LocalCacheRepository by 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:

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 reference to PersonCompanyLink table, which is required for model first approach, where database schema is generated from your entities.

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

Add-Migration InitialCreate

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

Update-Database

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

Changing Repository

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

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 Pesron, Company and PesronCompanyLink table using UI.

Summary

In this post I presented CrmLight seed project from BlazorForms open-source framework. It shows a straight forward approach on how to connect together database Repositories, application business logic and user interface. At 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 the my future posts I will focus on more complex scenarios, other types of Flows and will present more use cases that are possible to implement using BlazorForms.

Thanks for reading.

Remember you can always reach out to the Pro Coders team with any questions!