Full stack development with ASP.NET MVC Controls Toolkit Core

Abstract: Build a complete ASP.NET MVC Core web application from the DB layer to the UI using the Mvc Controls toolkit core free library. We will also show data from a database in a paged Grid with filtering/sorting/grouping and add/edit/delete capabilities.
 

The Mvc Controls Toolkit Core library is a free tool for building professional level ASP.NET Core MVC web sites with globalization/localization, client/server localized validation, browser capabilities detection, HTML5 input fallback, and many other tools to increase productivity.

Productivity tools encompasses all the layers a professional MVC application is composed of: data and business layer tools, controller tools and View tools that include several advanced tag helpers, for instance the grid tag helper.

In this article, I will show:

  • how to set-up a modular web project,
  • how to easily  build data/business layer with the help of Mvc Controls toolkit DB/Business layer tools,
  • how to build a powerful controller by inheriting from the ServerCrudController class,
  • how to build an add/edit/delete grid, and
  • how to empower the grid with filtering/sorting/ grouping capabilities thanks to the OData tools included in the Mvc Controls Toolkit library.

Setting up the project

Let us create an ASP.NET Core project in Visual Studio 2017 and call it “MvcctExample”.

Select “Web Application” and “no authentication” (I’ll show how to create a DB and a DB Context from the scratch). I’ll let you familiarize with all the modules included in the Mvc Controls Toolkit Core during the steps involved in the initial project set up.

Are you keeping up with new developer technologies? Advance your IT career with our Free Developer magazines covering C#, Patterns, .NET Core, MVC, Azure, Angular, React, and more. Subscribe to the DotNetCurry (DNC) magazine for FREE and download all previous, current and upcoming editions.

Installing the Mvc Controls Toolkit Core Nuget packages

It is enough to install the MvcControlsToolkit.ControlsCore package since all the other needed packages are installed as its dependencies.

Right click on the “Dependencies” node in solution explorer and select “handle nugget packages”. Then put “MvcControlsToolkit.ControlsCore” in the search box, select the 1.2.1 version (or the latest one as available) of the MvcControlsToolkit.ControlsCore packages as shown in the picture below and click “install”.

mvc-controls-toolkit-install

Now open the startup.cs file and in the “ConfigureServices”, locate:

services.AddMvc();

Add these two lines of code immediately after it:

services.AddMvcControlsToolkitControls();
services.AddODataQueries();

The first instruction adds all Mvc Controls Toolkit customizations to the ASP.NET MVC engine, while the second instruction adds support for OData queries.

Finally, in “Configure” immediately before:

app.UseMvc(routes =>....

add:

app.UseMvcControlsToolkit();

Installing the Mvc Controls Toolkit Core Bower packages

In the solution explorer, double click on Bower.json to open it, and then add:

"jquery-validation-unobtrusive-extensions": "^1.0.4",
"bootstrap-html5-fallback": "^1.0.3",
"mvcct-controls": "^1.0.5"

When you save the file, all the new Bower packages will be installed. The first package contains Mvc Controls Toolkit Core enhanced client validation (localized validation, and more validation rules).

The second package contains fallback for HTML5 inputs, and finally the third package contains all JavaScript needed by Mvc Controls Toolkit Core advanced controls.

Installing the Mvc Controls Toolkit Core npm packages

There are also some npm packages to install. Namely, the JSON database of all client side cultures, the globalize library to manage them, and a gulp-based scaffolder that adds some utility files to the project.

As a first step, you must add a package.json file to the project root.

Right click on the project icon and select “add”, and then “new element”. In the detail page, select the “Web” node and then “NPM configuration file”.

Open package.json and add the following items under "devDependencies":

"cldr-data": "29.0.1",
"globalize": "1.1.1",
"mvcct-templates": "1.1.5"

When you save the file, all the packages get installed.

The whole installation process may last a few minutes, since “cldr-data” contains a 30Mega DB of all main cultures, that must be downloaded and unzipped.

Don’t worry since, as we will see later in this article, the whole DB will not be installed when deploying the web site (you deploy just the culture you would like to support).

“mvcct-templates” scaffolds several files, namely:

  • The wwwroot/startupjs/startup.js file that defines all client side JavaScript options such as, when forcing html5 input fall back and the options for the JavaScript widgets used for the fall back
  • A gulp file and some gulp tasks. All tasks are contained in a tasks folder in the project root. globalize.tasks.js defines the task “min:globalize” for minimizing and moving all needed globalization modules to the web deployment area (wwwroot/lib). The task “move:gdata” is for moving the language specific files we need in the application to the deployment area. startup.tasks.js defines a unique task “min:startup”, for minimizing the scaffolded wwwroot/startupjs/startup.js file (see the previous bulleted point).
  • Some partial views in Views/Shared. They contain all JavaScript “script” and CSS “link” tags needed for Html5 fall back (_EnhancementFallbackPartial.cshtml and _FallbackCss.cshtml), client side globalization (_GlobalizationScriptsPartial.cshtml), and validation (_ValidationScriptsPartial.cshtml).

Configuring client side globalization

Client-side globalization features are defined in the globalize.tasks.js file.

The default configuration of the “min:globalize” adds all basic modules for handling numbers and dates/time. These modules are needed for HTML5 fall back and globalized validations, so you should not remove them.

However, you might also need modules for handing currency and relative time (that are commented out in the file), and other modules contained in the globalize library.

The default configuration of “move:gdata” tasks adds several variants of the English and French cultures, Spanish, German, and Italian. They add support for number and date formats for these languages and other useful language features that depend on the installed modules (see the documentation of the globalize library).

You may add or remove languages as needed.

Once you are satisfied with your configuration, right click on the gulpfile.js in the solution explorer and select “Task Runner”. A task runner where you may launch the “min:globalize” and “move:gdata” will appear:

task-runner

Configuring server side globalization

On the server side, we should support exactly the same languages added on the client side, at least for the date and number formats.

The script below adds server side support for the same default languages added on the client side for date and number formats, while it supports just English for the strings contained in resource files:

var supportedCultures = new[]
{
 
    new CultureInfo("en-AU"),
    new CultureInfo("en-CA"),
    new CultureInfo("en-GB"),
    new CultureInfo("en"),
    new CultureInfo("es-MX"),
    new CultureInfo("es"),
    new CultureInfo("fr-CA"),
    new CultureInfo("fr"),
    new CultureInfo("it-CH"),
    new CultureInfo("it"),
    new CultureInfo("de")
};
var supportedUICultures = new[]
{
    new CultureInfo("en"),
};
app.UseRequestLocalization(new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture("en", "en"),
 
    // Formatting numbers, dates, etc.
    SupportedCultures = supportedCultures,
    // UI strings that we have localized.
    SupportedUICultures = supportedUICultures,
    FallBackToParentCultures = true,
    FallBackToParentUICultures = true
});

Add the above script in the Configure method in Startup.cs, just before the app.UseStaticFiles(); instruction.

Feel free to add more resource file languages or to change languages supported for number and date formats. However please don’t forget to update the client side globalization too. For more details on sever side globalization, refer to the official Asp.net Core documentation.

Configuring HTML5 input fallback

Open the wwwroot/startupjs/startup.js file that contains all client side JavaScript options.

All Mvc Controls Toolkit Core JavaScript modules can be customized by adding custom options here. Most of the applications customize only the html5 input fall back options, but in general, here you may customize all of the Mvc Controls Toolkit Core JavaScript behaviors if needed, such as replacing Bootstrap models with other model implementations etc.

The default file looks like this:

(function () {
    var options = {};
    options.browserSupport = {
        cookie: "_browser_basic_capabilities",
        forms: null,
        fallbacks: {
            /*
            number: {
                force: true
            },
             
            range: {
                force: false
            },
            ...
            ...
            ...

The “cookie” property states that information on the html5 input browser support and fall back are send to the server in a cookie, and the cookie name.

Please avoid changing the cookie name since it should be changed also on the server side. Server needs this information to model bind the date and number fields properly. You may choose to send these data in a hidden field in a form by setting cookie to null while setting forms to “_browser_basic_capabilities”.

As a default, html5 input fall back on an input type is done only if that input type is not supported by the browser, however you may force html5 input fall back by uncommenting the commented lines and by setting force = true for any input type.

You may also disable the default fall back on a specific input type by setting the input type to undefined in the enhance property:

handlers: {
            enhance: {
                //datetime: undefined
            }
        }

In this case, if the browser doesn’t support that input type, the input will be transformed into a text input without enhancing it with any widget.

You may also  change the default options of all the widgets by providing your custom property values into an option object added to one of the properties like html5FallbackWidgets.date, html5FallbackWidgets.time, etc.

For more details on HTML5 fall back, please refer to the official documentation. For a detailed discussion on how the Mvc Controls Toolkit Core automatically enhances initial and ajax returned html with widgets and unobtrusive validation, please refer to the mvcct-enhancer official documentation.

Once you end up with the configuration, launch the “min:startup” task in the task runner, in order to minimize startup.js.

Adding JavaScript and CSS

All the client side features discussed so far need both CSS and JavaScript scripts be added to the page. Usually, all features, except client side validation are added globally to Views\Shared\_Layout.cshtml.

_FallbackCss.cshtml, that takes care of html5 input fallback CSS, must be added as shown below:

<title>@ViewData["Title"] - MvcctExample</title>
    @{ await Html.RenderPartialAsync("_FallbackCss"); }
    <environment names="Development">
        <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" />

..whereas all the required JavaScript files must be added just before the end of the body:

     @{ await Html.RenderPartialAsync("_GlobalizationScriptsPartial"); }
    @{ await Html.RenderPartialAsync("_EnhancementFallbackPartial"); }
    @RenderSection("Scripts", required: false)
    <script src="~/startupjs/startup.min.js"></script>
</body>

Please avoid removing the pre-existing “RenderSection” since we need it to add page specific JavaScript, as well as to ensure the startup.min.js script is the last one before the body tag.

Validation scripts will be added when needed, along with page specific JavaScript/CSS at the end of each View, as shown below:

@section Scripts {
    ...
    ...
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
    ...
    ...
}

Adding all the required folders

It is good practice to place classes with different roles into different folders. Then each folder in turn, may be organized as subfolders.

For instance, a ViewModels folder might have different subfolders, one for each controller.

In all the web projects, I usually add the following folders to the ones scaffolded by Visual Studio:

1. A ViewModels folder containing all ViewModels (just ViewModels, not DTOs)

2. A ViewComponents folder if my project contains ViewComponents, and a TagHelpers folder if my project contains custom tag helpers.

3. A Utilities folder, containing general purposes utilities.

4. A Services folder if my project contains any type of service (for instances services for resizing images).

Then I also use Business/Data layers folders. These folders may be placed either in a separate library project or in the same web project.

In this example, for simplicity, we don’t define a separate Business/Data Layer library, and place them directly in the Web project. The folders I use are:

1. A Repositories folder for all repository classes

2. A DTOs folder for all Data Transfer Objects.

3. A Models folder that contains all Entity Relationship models

4. A Data folder that contains DB contexts and other DB context related stuffs

5. A SqlScripts folder containing Sql scripts that must be deployed together with the project.

6. A Migrations folder that you MUST NOT add to the project since it is automatically scaffolded when you create your first database migration.

Summing up for our simple example project, you should add just the following folders: ViewModels, DTOs, Repositories, Models, and Data.

The Business/Data Layer

Database creation

In the example project, I’ll use a very simple products/supplier database. Add the following DB models to the Models folder:

using System.ComponentModel.DataAnnotations;
namespace MvcctExample.Models
{
    public class Food
    {
        public int Id { get; set; }
        [MaxLength(64), Required]
        public string ProductName { get; set; }
        [MaxLength(32), Required]
        public string Package { get; set; }
        public decimal UnitPrice { get; set; }
        public bool IsDiscontinued { get; set; }
        public int SupplierId { get; set; }
        public virtual Supplier Supplier { get; set; }
    }
}
 
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace MvcctExample.Models
{
    public class Supplier
    {
        public int Id { get; set; }
        [MaxLength(64), Required]
        public string CompanyName { get; set; }
        [MaxLength(128), Required]
        public string ContactName { get; set; }
        [MaxLength(64)]
        public string ContactTitle { get; set; }
        [MaxLength(64), Required]
        public string City { get; set; }
        [MaxLength(64), Required]
        public string Country { get; set; }
        [MaxLength(32), Required]
        public string Phone { get; set; }
        [MaxLength(32)]
        public string Fax { get; set; }
        public virtual ICollection<Food> Products { get; set; }
    }
}

Before going on with the database creation, we need to add references to all needed EF Nuget packages, and we must provide a connection string.

Right click on the project and select the modify project file option. Here, add the following references to the main “Item Group”:

<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="1.1.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="1.1.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer.Design"    Version="1.1.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="1.1.1" />

Then add two more item groups:

<ItemGroup>
  <DotNetCliToolReference Include="Microsoft.EntityFrameworkCore.Tools.DotNet" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
    <Content Include="SqlScripts\**\*" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>

The first item group adds console commands for handling migrations, while the second item group declares that the SqlScripts folder we added previously, must be deployed (here we will add all Sql scripts to populate our database).

Now open appsettings.json to add a connection string:

{
    ...
    ...
    "ConnectionStrings": {
        "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=MvcctExample;Trusted_Connection=True;MultipleActiveResultSets=true"
    }
}

Obviously, feel free to change the database name from “MvcctExample” to whatever you want. The database will be created when executing the first migration.

Now you may add a DB context to the Data folder:

using Microsoft.EntityFrameworkCore;
using MvcctExample.Models;
namespace MvcctExample.Data
{
    public class ApplicationDbContext : DbContext
    {
        public ApplicationDbContext
            (DbContextOptions<ApplicationDbContext> options)
            : base(options)
        {
        }
        public DbSet<Food> Foods { get; set; }
        public DbSet<Supplier> Suppliers { get; set; }
        protected override void OnModelCreating(ModelBuilder builder)
        {
            base.OnModelCreating(builder);
 
            builder.Entity<Supplier>()
                .HasMany(m => m.Products)
                .WithOne(m => m.Supplier)
                .HasForeignKey(m => m.SupplierId);
 
            builder.Entity<Supplier>()
                .HasIndex(m => m.CompanyName);
            builder.Entity<Food>()
                .HasIndex(m => m.ProductName);
            builder.Entity<Food>()
                .HasIndex(m => m.UnitPrice);
            builder.Entity<Food>()
                .HasIndex(m => m.IsDiscontinued);
        }
    }
}

The code above just defines two DBSets - a one to many relation between Suppliers and Foods, and a few indexes useful for our DB queries.

ApplicationDbContext must be added to Asp.net Core Dependency Injection framework, so that our repository classes, and the Migrations tool, may use it.

Go to Startup.cs and at the beginning of the ConfigureServices Method, add the following:

services.AddDbContext<Data.ApplicationDbContext>(options =>
    options.UseSqlServer(
        Configuration.GetConnectionString("DefaultConnection")));

Now we are ready to scaffold our initial (and unique) migration.

Using either the Nuget packages manager console or the standard windows console, move to the project folder (initially you should be in the solution folder, so a “cd MvcctExample” should be enough).

Here type: “dotnet ef migrations add initial”. A Migrations folder containing our first “initial” migrations will be created.

The initial DB might be created with the command: dotnet ef database update. Instead, we will execute our first migration and DB population script with a piece of code executed at application start.

This way, on our development machine, we have the opportunity to test the same piece of code  that will create and populate the DB in production.

Add the following static class to the Data folder :

using System.IO;
using System.Linq;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
namespace MvcctExample.Data
{
    public static class DbInitializer
    {
        public static void Initialize(ApplicationDbContext context,
            IHostingEnvironment hostingEnvironment)
        {
            context.Database.Migrate();
            var scriptsDir = Path.
                Combine(hostingEnvironment.ContentRootPath, "SqlScripts");
            if (!context.Suppliers.Any())
            {
                context.Database
                    .ExecuteSqlCommand(File.ReadAllText(
                        Path.Combine(scriptsDir, "Products.sql")));
            }
        }
    }
}

context.Database.Migrate() executes all pending migrations and creates the database if it doesn’t exist, yet.

If the Suppliers table is empty, we execute the “Products.sql” DB population script that is in the SqlScripts folder. Since in the project file we declared the SqlScripts folder, it must be deployed and the same script will be available also in production.

The “Products.sql” DB is available at the link that contains the whole example project.

It is enough to place:

DbInitializer.Initialize(context, env);

..at the end of the Configure method in Startup.cs. There, the Configure method doesn’t contain any ApplicationDbContext context parameter. Just add it at the end of the method parameters!

The Dependency Injection engine will take care of supplying a value for this parameter:

public void Configure(IApplicationBuilder app, IHostingEnvironment env,
    ILoggerFactory loggerFactory, ApplicationDbContext context)

Now compile, and run the project. The database will be created and populated automatically!

The Food repository

According to the Repository Pattern, Controllers communicate with the business layer by calling methods of interface classes called Repositories. Repositories do not return DB models, but Data Transfer Objects (DTOs) containing only the details needed by the caller.

The Mvc Controls Toolkit Core offers generic repository classes containing all CRUD methods and methods to pass complex queries getting paged data. DB models are automatically projected to DTOs according to name conventions and declarations.

In this simple project, we need just a Food repository for querying the Foods DBSet and for executing CRUD operations.

Let add it to the Repositories folder:

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MvcControlsToolkit.Core.Business.Utilities;
using MvcctExample.Data;
using MvcctExample.Models;
using MvcctExample.DTOs;
using Microsoft.EntityFrameworkCore;
namespace MvcctExample.Repositories
{
    public class FoodRepository :
        DefaultCRUDRepository<ApplicationDbContext, Food>
    {
        private ApplicationDbContext db;
        public  FoodRepository(ApplicationDbContext db):
            base(db, db.Foods)
        {
            this.db = db;
        }
        public async Task<IEnumerable<AutoCompleteItem>>
            GetSuppliers(string search, int maxitems)
        {
            return await db.Suppliers.Where(m => m.CompanyName.StartsWith(search))
                .Take(maxitems)
                .Select(m => new AutoCompleteItem
                {
                    Display = m.CompanyName,
                    Value = m.Id
                }).ToArrayAsync();
        
    }
}

Most of methods are inherited from DefaultCRUDRepository<ApplicationDbContext, Food>, we added just a method that returns all suppliers whose name starts with the “search” string. We need it for selecting suppliers with an autocomplete.

The AutoComplete item is a utility DTO you must add in the DTOs folder:

namespace MvcctExample.DTOs
{
    public class AutoCompleteItem
    {
        public string Display { get; set; }
        public int Value { get; set; }
    }
}

For all inherited methods, as we will see in the controller code, the specific DTO used by any method is passed as a generic argument.

Since DefaultCRUDRepository<C, M> is the default implementation of the ICRUDRepository interface, our repository implements the same interface and all inherited methods come from ICRUDRepository.

Our repository must be registered in the DI engine so that it may be automatically injected in the controller constructors. It is enough to add:

services.AddTransient<FoodRepository>();

at the end of the ConfigureServices method in Startup.cs.

How to use DefaultCRUDRepository<C, M> and ICRUDRepository

Since all query and CRUD operations defined in DefaultCRUDRepository<C, M> are quite powerful, involve connected entities, and can be customized with declarations; typically methods inherited from DefaultCRUDRepository<C, M> cover more than 70-80% of all application needs.

Other methods may be defined after having inherited from DefaultCRUDRepository<C, M>.

Also, when we don’t need any method defined in DefaultCRUDRepository<C, M> as it is, it is good practice to implement ICRUDRepository, possibly leaving it “empty” (throwing a NotImplementedException). Some methods may not get used, as we will see later on, ICRUDRepository is the interface used by some standard generic controllers to communicate with the business layer.

Custom implementations of ICRUDRepository, in turn, may use several DefaultCRUDRepository<C, M> to implements their methods.

The Food DTO

All food item exchanges between FoodRepository and controllers are conveyed by the Fodio class defined in the DTOs folder:

using System.ComponentModel.DataAnnotations;
using MvcControlsToolkit.Core.DataAnnotations;
namespace MvcctExample.DTOs
{
    public class FoodDTO
    {
        public int? Id { get; set; }
        [Query, StringLength(64, MinimumLength = 2), Required,
            Display(Name ="name")]
        public string ProductName { get; set; }
        [Query, StringLength(32, MinimumLength = 2), Required,
            Display(Name = "package type")]
        public string Package { get; set; }
        [Query,
            Display(Name = "unit price")]
        public decimal UnitPrice { get; set; }
        [Display(Name = "discontinued")]
        public bool IsDiscontinued { get; set; }
        [Query,
            Display(Name = "supplier")]
        public int SupplierId { get; set; }
        [Query,
            Display(Name = "supplier")]
        public string SupplierCompanyName { get; set; }
    }

Most of properties have the same name as the relative Food DB model properties. There are just a few differences:

  • Id is declared as int? instead of int, since the DTO might contain data of a new Food to add to the DB that has no Id since Ids are created by the database.
  • FoodDTO contains different annotation needed by the presentation layer, namely: display attributes that define the names of fields in all views, validation attributes, and the Query attribute that declares which OData queries can be accepted on each property. Property with no Query attribute can’t be involved in queries. The Query attribute has an enums flag to specify the allowed operations. If no argument is passed to the attribute a default that depends on the property type is assumed. All clauses containing not allowed operations and/or properties are automatically removed by the queries.
  • SupplierCompanyName doesn’t correspond to any property of the Food DB model but is obtained by chaining the Supplier property of the Food DB class with the CompanyName property of a Supplier DB model. The DefaultCRUDRepository class is smart enough to associate the nested property with SupplierCompanyName in both edit and query operations. The behavior of DTO properties that do not conform to the name conventions may be specified in declarations that are placed in the static constructor of the repository class (see DeclareProjection and DeclareQueryProjection).

We also need another DTO to handle items coming from grouping queries. We may place it in the same file as FoodDTO:

[RunTimeType]
public class FoodDTOGrouping : FoodDTO
{
    public int SupplierIdCount { get; set; }
    public int PackageCount { get; set; }
}

It inherits from FoodDTO and adds two more properties - one for counting Suppliers, and the other one for counting Packages.

If you need to count values of other properties, you must add more properties whose name must be the name of the property to count, followed by the “Count” postfix. Counting on properties without an associated  Count property, is not allowed.

The RunTimeType attributes is an Mvc Controls Toolkit Core security attribute that enables the framework to instantiate and cast to FoodDTOGrouping models declared as super classes of FoodDTOGrouping.

As a default, this operation is not allowed because a malicious user might exploit this capability to create “dangerous” classes. We need this attribute since FoodDTOGrouping must be recognized and handled properly by the same framework components configured to work with its superclass FoodDTO.

More complex applications may have several more DTOs corresponding to the same DB model, such as, for instance, a short model and a detail model.

The Food Controller

All CRUD needs of our application may be fulfilled by inheriting from the ServerCrudController<VMD, VMS, D> controller.

This generic abstract controller communicates with the business layer through the ICRUDRepository interface. Therefore, to have any kind of edit\create\delete operation implemented, it is enough to pass it either a class inheriting from DefaultCRUDRepository<C, M> or a custom implementation of ICRUDRepository.

Controller operations may be enabled by overriding its method:

protected virtual Functionalities Permissions(IPrincipal user)

Where Functionalities is a flag enum encoding all allowed operations. Enabled operations may depend on the logged in user.

As a default, all operations are allowed.

VMD is the DTO used for “detail” operations, VMS the DTO used for “list” and “in-line” editing operations and D is the type of the item principal key.

The ICRUDRepository, and other services needed by the controller must be defined as constructor parameters. They will be automatically filled by the Dependency Injection engine.

In our project, we don’t use a detail DTO so both VMS and VMD are FoodDTO:

using MvcControlsToolkit.Controllers;
using MvcControlsToolkit.Core.OData;
using MvcctExample.DTOs;
using MvcctExample.Repositories;
using MvcctExample.ViewModels;
namespace MvcctExample.Controllers
{
    public class FoodController :
        ServerCrudController<FoodDTO, FoodDTO, int?>
    {
        public IWebQueryProvider queryProvider { get; private set; }
        public FoodController(FoodRepository repository,
            IStringLocalizerFactory factory,
            IHttpContextAccessor accessor,
            IWebQueryProvider queryProvider)
            : base(factory, accessor)
        {
            Repository = repository;
            this.queryProvider = queryProvider;
        }

Our repository is injected by DI and assigned to the ICRUDRepository Repository property inherited from ServerCrudController.

IStringLocalizerFactory is ASP.NET Core standard interface for localizing strings, and IHttpContextAccessor is ASP.NET Core standard interface to get the current HttpContext object.

IWebQueryProvider, instead, is Mvc Controls Toolkit standard interface to access OData query services, we need it to get the OData query passed in the request URL.

Since all CRUD operations are automatically handled by ServerCrudController we must implement just the Index method that shows a page of data, and the utility action method that retrieves suppliers for autocompletes that select suppliers.

Let start with the utility method that is simpler:

[HttpGet]
public async Task<ActionResult> GetSuppliers(string search)
{
    var res = search == null || search.Length < 3 ?
        new List<AutoCompleteItem>() :
        await (Repository as FoodRepository)
            .GetSuppliers(search, 10);
    return Json(res);
}

The code is self-explicative, we just call the GetSuppliers method we defined in our repository.

The Index method must pass the following information to the View:

- Current data page, that includes the total number of pages, the total number of items and the items in the current page. The Mvc Controls Toolkit has a specific DataPage<T> class for this, that is returned by all ICRUDRepository query methods.

- The current query passed in the URL. OData queries are encoded in QueryDescription<T> classes. We need the current query in the view to set the pager, and to fill all query windows with the values previously inserted by the user. In all views, it is enough to pass a QueryDescription that is a not generic superclass of QueryDescription<T>.

Accordingly, the Index method ViewModel (placed in the ViewModels folder) is defined as:

using MvcControlsToolkit.Core.Business.Utilities;
using MvcControlsToolkit.Core.Views;
using MvcctExample.DTOs;
namespace MvcctExample.ViewModels
{
    public class FoodListViewModel
    {
        public DataPage<FoodDTO> Products { get; set; }
        public QueryDescription Query { get; set; }
    }
}

Here’s the Index method:

[ResponseCache(Duration = 0, NoStore = true)]
public async Task<ActionResult> Index()
{
    var query = queryProvider.Parse<FoodDTO>();
    int pg = (int)query.Page;
    var grouping = query.GetGrouping<FoodDTOGrouping>();
    var model = new FoodListViewModel
    {
        Query = query,
        Products =
            grouping == null ?
                await Repository.GetPage(
                    query.GetFilterExpression(),
                    query.GetSorting() ??
                        (q => q.OrderBy(m => m.ProductName)),
                    pg, 5)
                :
                await Repository.GetPageExtended(
                    query.GetFilterExpression(),
                    query.GetSorting<FoodDTOGrouping>() ??
                        (q => q.OrderBy(m => m.ProductName)),
                    pg, 5,
                    grouping )
    };
    return View(model);
}

Since data may be edited, we prevent caching with the ResponseCache attribute.

As a first step, we call the Parse method of the IWebQueryProvider queryProvider property, to get a QueryDescription<FoodDTO> instance with the query passed in the URL. Then we extract the current page from the Page property, and a LINQ representation of a possible grouping clause by calling the GetGrouping<FoodDTOGrouping>() method. We pass it our grouping specific DTO: FoodDTOGrouping.

If there is no grouping, we call the simpler overload of the ICRUDRepository GetPage method, passing it:

  • the LINQ boolean expression that must be used to filter data, obtained by calling the GetFilterExpression QueryDescription<FoodDTO> method.
  • The current LINQ sorting obtained with GetSorting()
  • The current page, and the number of items per page we prefer.

If “grouping” is not null, we call a different overload of GetSorting that contains the grouping DTO as generic argument, since sorting is applied after grouping, and the GetPageExtended method has a generic argument for the query DTO, and accepts a LINQ grouping based on the FoodDTOGrouping DTO.

Once we have our controller ready, we may add a link for its Index method in the web site menu contained in the _Layout page:

<ul class="nav navbar-nav">
  <li><a asp-controller="Home" asp-action="Index">Home</a></li>
  <li><a asp-controller="Food" asp-action="Index">Mvcct Grid Example</a></li>

View with the Grid

Right click on the Index method name and select “add view” to add a view for this action method. In the view wizard, select “empty without model” (so we may add everything from the scratch), and select the “use layout page” checkbox.

Now add this initial code:

@model  MvcctExample.ViewModels.FoodListViewModel
@using MvcctExample.DTOs
@using MvcctExample.ViewModels
@{
    ViewData["Title"] = "Mvcct Grid Example";
    if (Model.Query.AttachedTo == null)
    {
        Model.Query.AttachEndpoint(Url.Action("Index", "Food"));
    }
}
<h2>@ViewData["Title"]</h2>
 
 
<form asp-antiforgery="true">
    <div asp-validation-summary="All" class="text-danger"></div>
     
</form>
@section Scripts {
 
    <link href="~/lib/awesomplete/awesomplete.css" rel="stylesheet" />
    @{await Html.RenderPartialAsync("_ValidationScriptsPartial"); }
    <script src="~/lib/mvcct-controls/mvcct.controls.min.js"></script>
    <script src="~/lib/mvcct-controls/modules/mvcct.controls.ajax.min.js"></script>
    <script src="~/lib/awesomplete/awesomplete.min.js"></script>
    <script src="~/lib/mvcct-controls/modules/mvcct.controls.autocomplete.min.js"></script>
    <script src="~/lib/mvcct-controls/modules/mvcct.controls.serverGrid.min.js"></script>
    <script src="~/lib/mvcct-odata/dest/global/mvcct.odata.min.js"></script>
    <script src="~/lib/mvcct-controls/modules/mvcct.controls.query.min.js"></script>
}

The initial code adds the base URL to submit all queries to the QueryDescription object contained in the ViewModel.

Then we define a typical form that might work with any edit view, and all page specific JavaScript and CSS files. You may bundle all these files by adding the needed definitions to bundleconfig.json.

We have the CSS and JavaScript needed for autocomplete. The standard client validation scripts, mvcct.controls.min.js must be included in any view that uses Mvc Controls Toolkit TagHelpers, and mvcct.controls.serverGrid.min must be added whenever we use a grid.

mvcct.odata.min.js is a JavaScript OData client, while mvcct.controls.query.min.js is an interface between the query windows and the OData client.

Compile the project and run it. Select the link you added to the web site menu to verify if the code added so far works.

Templated controls and RowTypes

The grid tag helper renders an Mvc Controls Toolkit Core templated control, that is a control based on customizable templates.

More specifically, all templated controls offer default templates that the developer may substitute and customize. They have a layout template that defines the container where all data are placed. Each Layout template contains an area to place all the data, and various toolbars areas, where the developer may place any kind of html and tag helpers.

The way to render an item of data is defined by a row-type tag helper, while the way to render each item property may be customized by a column tag helper.

Each control has a main row-type whose attributes are specified in the control tag helper itself. In other words, each templated control tag also contains all row-type attributes, and may have all children tag helper a row-type may have.

Controls may specify additional row-types that define how to render specific sub types of the item type.

For instance, our grid tag helper will specify a row-type for the FoodDTOGrouping class that is used when grid items are grouped. As a default, the row-type to use for each item is selected with a type match, but the developer may specify a custom row-type selection function.

More specifically, as a default, all row-types are analyzed in reverse order (starting from the last defined) until the one whose type is compatible with the current item, is found.

Adding a Simple Grid

Adding a very simple grid is straightforward! Just add the code below inside the form tag:

<grid asp-for="Products.Data"
        type="Immediate"
        all-properties="true"
        row-id="readonly-example"
        operations="user => Functionalities.ReadOnly "
        class="table table-condensed table-bordered" />

Where:

  • The asp-for attribute, as usual, specifies the IEnumerable to render with the grid.
  • type may be either “Immediate” or “Batch”. In Immediate grids, modifications (edits/creations/deletes) are processed immediately, while in batch grids all modifications are collected and executed when the user submits the form. Read only grids must be “Immediate”.
  • The operations attribute specifies the operations enabled in the grid (edit/delete/show detail/show, etc.). In our example, we are just showing data.
  • class is the CSS class to apply to the root html tag. Since the default template is based on a <table> our CSS settings defines a Bootstrap table.
  • all-properties is inherited by the row-type role all templated tag helpers have, and specifies that the main row-type of the grid must include all data item properties.
  • row-id is inherited from row-type too, and it is a name that identifies univocally the row-type among all row-types whose modifications (edits/creations/deletes) are handled by the same controller. For read-only grids, it may be omitted, but it is good practice to add it anyway, to avoid errors if grid operations are changed.

Run the project and have a look at the grid.

Relative order of columns may be changed either with the Order property of the DisplayAttribute or with the priority attribute of the column tag helper. Widths of columns may be controlled either with the ColumnLayoutAttribute or with the width attribute of the column tag helper. More details on the column tag helper documentation.

Three facts are worth pointing out .

Fact one - The grid shows just the first page of data. This may be fixed either by adding a stand-alone pager, or by adding full OData query support (that includes a connected pager). We will add OData query support.

Fact Second - The Id attribute is not shown. It is automatically recognized as item key, and rendered in “hidden” mode. If the key has a different name we may specify it with the “key” attribute (inherited from row-type). If instead, the item key must be shown, redefine the default way to show the Id column with a column tag helper placed inside the grid tag helper (try it!):

<column asp-for="Products.Data.Element().Id"/>

After the test remove the instruction.

Fact Third - Because of all-properties=true, a SupplierId column is shown together with the supplier name. We may remove it (or any other column) by placing the following column tag helper inside the grid tag helper (or inside to any tag helper that inherits from row-type):

<column asp-for="Products.Data.Element().SupplierId" remove="true"/>

However, editable grids and filter windows also need SupplierId to select suppliers either with a “select” or with an autocomplete. Thus, the best fix is informing the grid that SupplierId is an external key whose display value is SupplierCompanyName with a column definition:

<column asp-for="Products.Data.Element().SupplierId">
    <external-key-remote display-property="Products.Data.Element()
        .SupplierCompanyName"
        items-value-property="Value"
        items-display-property="Display"
        items-url="@(Url.Action("GetSuppliers", "Food",
        new { search = "_zzz_" }))"
        dataset-name="suppliers"
        url-token="_zzz_"
        max-results="20" />
 
</column>

The above code adds autocomplete support to the SupplierId column.

If you run the project, the SupplierId column will not be shown, but you can’t see any autocomplete since the grid is  in read-only mode. We will see it when adding modifications support and filtering.

Most of attributes are self-explicatory: items-url is the url where to retrieve suppliers, url-token is the token that will be replaced by the actual search string, while items-value-property, and items-display-property are the item properties containing the SupplierId and the supplier name. We could have also added a dropdown by using the external-key-static tag helper, instead.

Keep the above code, and move ahead.

Modifications support may be added by specifying the controller that will handle all modifications, by changing the allowed operations, and by adding a toolbar with a creation button:

<grid asp-for="Products.Data"
        type="Immediate"
        mvc-controller=
            "typeof(MvcctExample.Controllers.FoodController)"
        all-properties="true"
        row-id="readonly-example"
        operations="user => Functionalities.FullInLine"
        class="table table-condensed table-bordered" >
    <column asp-for="Products.Data.Element().SupplierId">
        <external-key-remote display-property="Products.Data.Element()
            .SupplierCompanyName"
            items-value-property="Value"
            items-display-property="Display"
            items-url="@(Url.Action("GetSuppliers", "Food",
            new { search = "_zzz_" }))"
            dataset-name="suppliers"
            url-token="_zzz_"
            max-results="20" />
    </column>
    <toolbar zone-name="@LayoutStandardPlaces.Header">
        <verify-permission
                required-permissions="@(Functionalities.Append)">
            <button type="button" data-operation="add append 0"
                    class="btn btn-sm btn-primary">
                new product
            </button>
        </verify-permission>
    </toolbar>
</grid>

The toolbar is placed in the “Header” area of the control layout (table header in the default layout).

The data-operation button attribute specifies it is a creation button that appends a new item at the end of the grid.

0 specifies row-type to use for rendering the newly created empty row. All row-types are numbered in the order they are defined starting from row-type 0 that is the main row-type defined with the grid tag helper itself.

Later on, we will add one more row-type for the grouped items, so we need to specify which row-type to use. In more complex grids where user may create several sub classes of the grid items we must provide a different row-type and a different creation button for each sub-type.

Creation buttons must always be included within a verify-permission tag helper so that they are shown only when the current user has the permission to “Append” new items (user permissions are defined with the operation grid attribute).

In our example, permissions do not depend on the user but we need the verify-permission tag helper to hide creation buttons when grid is in grouping mode. In fact, when grid is in grouping mode, all modifications are automatically denied to all users, since it makes no sense to modify/delete/create a grouped item.

Grid appearance in edit mode may be improved by adding the following CSS to wwwroot\css\site.css:

.full-cell {
    width: 100%;
}
 
table .input-validation-error {
    border: 1px solid red;
    background-color: mistyrose;
}

The first rule makes the input field fill the whole table cell when a grid row is in edit mode, since all grid input fields have the full-cell CSS class.

The second rule gives a reddish appearance to input fields in error when a grid row is in edit mode, thus giving an immediate feedback to the user (there is no room for error messages in the grid cells).

Run the project, and test all operations. When a row is in edit mode the supplier field is implemented with an autocomplete. Suggestions appear after the user writes at least 3 characters.

Now it is time to add query support. It is enough to add a few more attributes - the grid, a pager, the needed query buttons to the toolbar, and a row-type for grouped items:

<grid asp-for="Products.Data"
        type="Immediate"
        mvc-controller=
            "typeof(MvcctExample.Controllers.FoodController)"
        all-properties="true"
        row-id="readonly-example"
        operations="user => Functionalities.FullInLine
                | Functionalities.GroupDetail"
        query-for="Query"
        sorting-clauses="2"
        enable-query="true"
        query-grouping-type="typeof(FoodDTOGrouping)"
        class="table table-condensed table-bordered" >
    <column asp-for="Products.Data.Element().SupplierId">
        <external-key-remote display-property="Products.Data.Element()
            .SupplierCompanyName"
            items-value-property="Value"
            items-display-property="Display"
            items-url="@(Url.Action("GetSuppliers", "Food",
            new { search = "_zzz_" }))"
            dataset-name="suppliers"
            url-token="_zzz_"
            max-results="20" />
    </column>
    <row-type asp-for="Products.Data.SubInfo<FoodDTOGrouping>().Model"
              from-row="0">
        <column
           asp-for="Products.Data.SubElement<FoodDTOGrouping>().SupplierIdCount" />
        <column
           asp-for="Products.Data.SubElement<FoodDTOGrouping>().PackageCount" />
    </row-type>
    <toolbar zone-name="@LayoutStandardPlaces.Header">
        <pager class="pagination pagination-sm"
               max-pages="4"
               page-size-default="5"
               total-pages="Products.TotalPages" />
        &nbsp;
        <query type="Filtering" />
        <query type="Sorting" />
        <query type="Grouping" />
        <verify-permission
                required-permissions="@(Functionalities.Append)">
            <button type="button" data-operation="add append 0"
                    class="btn btn-sm btn-primary">
                new product
            </button>
        </verify-permission>
    </toolbar>
</grid>

query-for specifies the property containing the current query (it is needed for rendering query related stuffs). sorting-clauses is inherited from row-type and specifies the maximum number of sorting conditions the user may add (the ones shown in the sorting windows).

The developer may also specify how many filtering conditions to allow on each property, either with a filter-clauses attribute in a column tag helper or with a FilterLayout data annotation on the DTO property. The operations attribute contains the new GroupDetail operation that when the grid is in grouping mode, adds a groups detail button to each grid row to show all items that have been grouped in that row.

enable-query (inherited from row-type) must be set to true. Finally, we must specify how grouped items are represented with the query-grouping-type attribute, and how they are rendered with a specific row-type definition.

The grouped items row-type inherits all settings from the main row-type (from-row=0), and add two more properties. Please notice the .SubInfo<FoodDTOGrouping>() in the row-type and the .SubElement<FoodDTOGrouping>() in the columns that specifies the subclass of the grid item the two tag helpers refer to.

The pager needs just a few attributes: the root CSS class (we are using a Bootstrap style pager), the maximum number of page links to show before and after the current page, the page size that must be the same as the one used in the controller, and the property containing the total number of pages.

There are three query buttons for the three different query operations. We may add just some of them. We may also add in-line forms instead of buttons that open query windows. Full documentation on query buttons and in-line query forms is here.

Run the project, and play with filters and sorting. Filtering on suppliers is implemented with autocomplete, the user may select either a specific supplier or just a few supplier name starting characters.

Now reset all filters with the filter window reset button and try a grouping. For instance, select the following settings:

mvc-grouping-example

The resulting table has columns just for the properties involved in the grouping operation. Each row has also a button that when clicked shows all items aggregated in that row in a grid.

Conclusion:

This article showed how Mvc Controls Toolkit may help writing the code of a web application from the business layer to the controllers, and finally the views. Standard modules and controls have default, customizable implementations like globalized inputs, advanced validation and validation rules, queries, grids, controllers, and repositories. This way the developer may speed up standard and near-to-standard tasks, saving time for the application peculiarities, and priorities.

Download the entire source code of this article from here (Github).

When the project is opened, Nuget, Bower, and npm packages should restore automatically. npm restore might last a few minutes since the cldr-data package downloads and unzips the whole international cultures JSON database. As soon as package restore is completed, right click on the gulp file in the project root, open the task runner and run all three tasks: “move:gdata”, “min:globalize”, and “min:startup” as explained in the article.

Add comment