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:
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:
Picture 2
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
{
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:
using CrmLightDemoApp.Onion.Domain;
using CrmLightDemoApp.Onion.Domain.Repositories;
namespace CrmLightDemoApp.Onion.Infrastructure
{
public class CompanyRepository : LocalCacheRepository<Company>, ICompanyRepository
{
public CompanyRepository()
{
_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:
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:
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:
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:
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:
@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:
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
:
@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:
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.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
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");
}
}
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.
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:
public class FormCompanyEdit_ItemDeletingRule : FlowRuleBase<CompanyModel>
{
public override string RuleCode => "CMP-1";
public override void Execute(CompanyModel model)
{
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 users click Edit button, they will see:
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:
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:
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 LocalCacheRepository
. It consumes CrmContext
to execute queries in the 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 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