Static Files

In KIT there are two types of static files assets and site assets. Site assets differ from regular assets as they are generated at runtime by users of your site.

Assets

Let's start by setting up our module so that we can consume assets and show a brief example of how to use them:

Open up the module's project file and add the following (inside the property group):

<StaticWebAssetBasePath>assets/shop</StaticWebAssetBasePath>

Note: You would replace "shop" with a unique name for your module.

This is a standard feature in ASP.NET Core. Click here for more information.

So far all we've done is override the default path. Now say for example you had a file called “test.png” which you placed within the “wwwroot/images” folder. You could reference this in your view by saying:

<img src="@Url.Asset("/shop/images/test.png")" alt="Test" />

Client-side Scripting

A standard KIT website utilises a JavaScript library called RequireJs, which allows us to use a pattern known as AMD to asynchronously load our client-side assets as they are required. RequireJS works by including a script reference to the library and then specifying a default file to load in the "data-main" attribute. This default file is known as the script entry point.

Currently we have pulled in our client-side dependencies in the view directly. As we start to attain more libraries this can look a little messy. Let's take this opportunity to refactor our module and tidy this up:

Open both the "NewProduct.cshtml" and "EditProduct.cshtml" view files and replace the script import with the following:

<script at="@HtmlPosition.HeadPostContent" type="module" src="@Url.Asset("/shop/js/entry/admin/new-and-edit-product.js")"></script>

Note: The at attribute allows you to move the position where the script is rendered.

Next we'll add a JavaScript file which will be the entry point for both our “NewProduct” and “EditProduct” views. By convention we always place our JavaScript files in the "Assets/js" folder. Assets are compiled using Babel which allows us to write to the latest standards (and still target older browsers).

Since the JavaScript file is specific to a view (or multiple views in this case) we will place it in the "Assets/js/entry/{kebab-case(ControllerName)}" folder where the name of the file is the name of the view(s). Create the following file called "new-and-edit-product.js" in the "Assets/js/entry/admin" folder:

import 'list.reorder';

onLoad();

export function onLoad(context = document) {
    // DOM loaded...
}

Next we need to make KIT aware of this file. Create an “Assets.json” file in the root of the project with the following content:

[
  {
    "inputFiles": [
      "Assets/js/entry/admin/new-and-edit-product.js"
    ],
    "outputFileName": "wwwroot/js/entry/admin/new-and-edit-product.js"
  }
]

If you are using Visual Studio then you are done. If not then you will need to execute the following command to compile the code (using Gulp):

gulp watch

Note: This only needs to be executed once as it will “watch” for future file changes within the “Assets” folder and automatically compile them.

Click here for a deeper dive into client-side scripting.

Style Sheets

Like JavaScript files above we can use the same technique for our style sheets. However instead of Babel we will use Less to compile the code. By convention you should place your Less files in a “less” folder within your “Assets” folder with the “.less” file extension.

Site Assets

Next we're going to add an image to our product to best demonstrate site assets.

First we'll add the field to the database by creating a migration called "Migration12.cs" with the following content:

using FluentMigrator;

namespace DemoShop.Shop.Migrations;

[Migration(201908141850)]
public class Migration12 : AutoReversingMigration {
    public override void Up() {
        Alter.Table("Products").AddColumn("Image").AsString(255).Nullable();
    }
}

Next open the "Product.cs" file in the "Models" directory and add the following property:

public virtual string? Image { get; set; }

Now we just need to map the property to the field in the database, let's add the following to the constructor of the "ProductMapping.cs" file:

Property(x => x.Image);

With the model taken care of we now need to add a property to our view model to store the image as it's posted back from the form. Open the "ViewModels/Admin/ProductFormViewModel.cs" file and add the following properties (adding any missing namespaces):

[FileType("bmp,gif,ico,jpg,jpeg,png"), MaxFileSize, MaxImageSize]
public IFormFile? Image { get; set; }

public string? ExistingImage { get; set; }

public bool RemoveImage { get; set; }

Note: The RemoveImage property is used on the "EditProduct" action to remove the existing image.

You then need to make sure you set the existing image in the “Initialize” method, like so:

ExistingImage = product.Image;

Note: Just a reminder that the above code goes in the “Initialize” method and not the constructor since the data is not posted from the form.

The final change to the view model is to save the image and set it against the model once the form is submitted. Add the following to the “ToModel” method and rename the method to “ToModelAsync” (adding any missing namespaces and changing the method to return an async Task):

if (Image != null || RemoveImage) {
    // Delete the existing image (if applicable).
    if (!string.IsNullOrEmpty(product.Image))
        File.Delete(Path.Combine(siteAssetsPath, "products", product.Image));

    // Save the image (if applicable).
    product.Image = Image != null ? await Image.SaveAsync(Path.Combine(siteAssetsPath, "products")) : null;
}

This won't compile as we need to specify the siteAssetsPath. Since we wish to keep the model as the first parameter then we must modify the signature of the method (since optional parameters must come last). Replace the parameters of the ToModelAsync method with the following:

Product product, string siteAssetsPath

You should also remove the following conditional statement as it's no longer needed:

if (product == null)
    product = new Product();

Now we need to add the following overload (above this method) which handles the case where we don't feed in the product:

public async Task<Product> ToModelAsync(string siteAssetsPath) {
    return await ToModelAsync(new Product(), siteAssetsPath);
}

Next we'll feed in this path into the method. Open the “AdminController.cs” file and inject the following dependencies and set them within the constructor:

private readonly IWebHostEnvironment _environment;
private readonly ISiteSettings _siteSettings;

Now we can feed the following into our “ToModelAsync” method (this was previously called “ToModel”) and await it accordingly:

_siteSettings.GetSiteAssetsPath(_environment.WebRootPath)

The next logical step is to add the image uload functionality to our form. Open the "NewProduct.cshtml" file and add the following within the details card's body:

<div class="form-group">
    <label asp-for="Image" class="form-label"></label>
    <editor for="Image" view-data-allowimagemodifying="true" />
</div>

We must also change the form's encoding to "multipart/form-data" by adding the following attribute to our form:

enctype="multipart/form-data"

Now open the "EditProduct.cshtml" file and add the following within the details card's body:

@if (!string.IsNullOrEmpty(Model.ExistingImage)) {
    <div class="form-group">
        <label asp-for="ExistingImage" class="form-label"></label>
        <div><img src="@await Url.SiteAssetAsync($"/products/{Model.ExistingImage}", 100, 100)" alt="" /></div>
        <div class="form-check">
            <input asp-for="RemoveImage" class="form-check-input" />
            <label asp-for="RemoveImage" class="form-check-label"></label>
        </div>
    </div>
}
<div class="form-group">
    <label asp-for="Image" class="form-label"></label>
    <editor for="Image" view-data-allowimagemodifying="true" />
    @if (!string.IsNullOrEmpty(Model.ExistingImage)) {
        <div class="form-text text-muted">@Text["Leave blank if you do not wish to change."]</div>
    }
</div>

Note: This displays the current image, as well as giving you the option to delete it since it is not required.

Similarly to what we did to the new product view we also need to change the form's encoding by adding the following attribute to our form:

enctype="multipart/form-data"

Finally we'll update our product display template to show the image (if one exists). Open the "Views/Shared/DisplayTemplates/Product.cshtml" file and replace it with the following:

@model DemoShop.Shop.ViewModels.Home.ProductViewModel
<article class="col-md-4">
    <h2><a asp-action="Details" asp-route-id="@Model.Id">@Format(Model.Name)</a></h2>
    @if (!string.IsNullOrEmpty(Model.Image)) {
        <p class="image text-center"><a asp-action="Details" asp-route-id="@Model.Id"><img src="@Url.SiteAsset($"/products/{Model.Image}")" alt="@Format(Model.Name)" /></a></p>
    }
    @if (!string.IsNullOrEmpty(Model.BrandName)) {
        <p>@Text["Brand"]: @Format(Model.BrandName)</p>
    }
    <p>@Format(Model.Price.ToString("c"))</p>
</article>

Now we just have to add the image to the view model. Open the “ViewModels/Home/ProductViewModel.cs” file and replace it with the following:

using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using DemoShop.Shop.Models;
using Kit.SiteMap;

namespace DemoShop.Shop.ViewModels.Home;

public class ProductViewModel : ISiteMapResolve {
    private readonly Product _product;

    public ProductViewModel(Product product, bool includeFeaturesAndRelatedProducts = false) {
        _product = product;
        
        Id = product.Id;
        Name = product.Name;
        BrandName = product.Brand?.Name;
        Price = product.Price;
        Image = product.Image;

        if (includeFeaturesAndRelatedProducts) {
            Features = product.Features.OrderBy(f => f.Order).Select(f => f.Name).ToList();
            RelatedProducts = product.RelatedProducts.Select(p => new ProductViewModel(p)).ToSortedList(p => p.Name, ListSortDirection.Ascending);
        }
    }

    public int Id { get; }
    public string Name { get; }
    public string? BrandName { get; }
    public decimal Price { get; }
    public string? Image { get; }
    public IList<string>? Features { get; }
    public IList<ProductViewModel>? RelatedProducts { get; }

    #region ISiteMapResolve Members

    public ISiteMapNode GetNode(ISiteMapNode siteMapNode) {
        // Set the name, head and body.
        siteMapNode.Name = Name;
        siteMapNode.SetHead(_product.Head);
        siteMapNode.SetBody(_product.Body);

        return siteMapNode;
    }

    #endregion
}

Now let's navigate to https://localhost:5001/admin/products where we can either add some more products with an image or edit the existing ones.

Services »