Creating a Dynamic Dialog System with Bootstrap and ASP.NET 5

July 7th, 2021

With the anniversary of Bootstrap next month, today's post explains how to take a modal and make it more dynamic using ViewComponents

Since we're coming up on the 10th anniversary of Bootstrap, I felt this would be a good time to write a post describing how to use Bootstrap in your ASP.NET Core.

I know I mentioned in the past about how JavaScript frameworks take up way too much space and slow down the loading of webpages, but Bootstrap is a library and has fundamental elements every web application uses.

I've tried to move away from Bootstrap by creating my own CSS grid system and JavaScript library of elements (like dropdown, modal, etc), but there were 2 reasons why I stopped.

  1. The world doesn't need another JavaScript library
  2. I couldn't get my library small enough to include all of the features already included in Bootstrap

In today's post, we'll create the happiest dialog on the Internet using Bootstrap and ASP.NET ViewComponents.

Overview

The project will display a button on the screen and when you press it, a dialog will appear with a loading indicator. When the GET finishes loading the content, the ViewComponent will replace the indicator.

Our demo project uses ASP.NET 5.0, Razor Pages, Bootstrap, FontAwesome, and TypeScript. That is all we need.

So no Controllers are in this house.

Creating the Project

First steps was to create the project.

  1. Select File/New...
  2. Select the "ASP.NET Core Web App"
  3. Fill out the project locations (directories) and Click Next
  4. Select ".NET 5.0" for the framework
  5. Click Create

Now that we have our project, we need to add the client-side specifics.

Setting up the Task Runner

Visual Studio's Task Runner will help us out with compiling our TypeScript into vanilla JavaScript.

Let's create our package.json in the root of our project.

/package.json

{
  "name": "dialogexample",
  "version": "1.0.0",
  "private": true,
  "devDependencies": {
    "@babel/core": "^7.13.8",
    "@fortawesome/fontawesome-free": "^5.15.3",
    "@types/gulp": "^4.0.8",
    "babel-preset-es2015": "^6.24.1",
    "babelify": "^7.3.0",
    "bootstrap": "^5.0.0",
    "browserify": "^13.3.0",
    "del": "^5.1.0",
    "gulp": "^4.0.2",
    "gulp-babel": "^8.0.0",
    "gulp-browserify": "^0.5.1",
    "gulp-clean": "^0.4.0",
    "gulp-concat": "^2.6.1",
    "gulp-htmlmin": "^5.0.1",
    "gulp-minify": "^3.1.0",
    "gulp-print": "^5.0.2",
    "gulp-sourcemaps": "^2.6.5",
    "gulp-typescript": "^6.0.0-alpha.1",
    "gulp-uglify": "^3.0.2",
    "merge-stream": "^2.0.0",
    "ts-loader": "^4.4.2",
    "ts-node": "^9.1.1",
    "tsify": "^4.0.2",
    "typescript": "^4.1.5",
    "vinyl-buffer": "^1.0.1",
    "vinyl-source-stream": "^2.0.0",
    "vinyl-transform": "^1.0.0"
  },
  "dependencies": {
    "gulp-rename": "^2.0.0",
    "node-typescript": "^0.1.3",
    "nvm": "0.0.4",
    "rebuild": "^0.1.2"
  }
}

Once we have the package.json defined, we need to restore these packages for our Task Runner to run properly.

In the Solution Explorer, right-click on the package.json file and click "Restore Packages". This will download the packages into your node_modules directory which is not visible in your solution unless you "Show All Files."

Directory Structure

Our directory structure will be similar to the previous CSS Bloat post. While everyone has their own style of organizing their projects, make sure you update your HTML to point to the proper locations.

The directory structure for this project looks like this:

Whether it's JavaScript or CSS, this type of organization makes your components easier to find in your project.

Next? GULP!

With all of our modules installed, we can now create our gulpfile.js.

Our gulp file will perform the following tasks:

  1. Copy the appropriate libraries (bootstrap, fontawesome) into the /wwwroot/lib directory.
  2. Convert the TypeScript into JavaScript
  3. Copy the resulting .js files over to the /wwwroot/js folder.

Since we aren't making changes to the CSS, I didn't include any SCSS tasks for our Task Runner.

Your gulpfile will be basic as shown below.

/gulpfile.js

/// <binding BeforeBuild='default' />
var path = require('path'),
    gulp = require('gulp'),
    gp_clean = require('gulp-clean'),
    sourcemaps = require('gulp-sourcemaps'),
    uglify = require("gulp-uglify"),
    buffer = require('vinyl-buffer'),
    source = require('vinyl-source-stream'),
    rename = require("gulp-rename"),
    browserify = require("browserify"),
    ts = require("gulp-typescript");

const
 basePath = path.resolve(__dirname, "wwwroot"); const modulePath = path.resolve(__dirname, "node_modules");
var
 tsProject = ts.createProject('tsconfig.json');
var
 srcPaths = {     lib: [         {             src: path.resolve(modulePath, 'bootstrap/dist/**/*'),             dest: path.resolve(basePath, 'lib/bootstrap/')         },         {             src: path.resolve(modulePath, '@fortawesome/fontawesome-free/**/*'),             dest: path.resolve(basePath, 'lib/fontawesome/')         }     ],     srcJs: path.resolve(basePath, 'src/**/*.js'),     js: [         path.resolve(basePath, 'src/dialogexample.js')     ] };
var
 destPaths = {     js: path.resolve(basePath, 'js') };
gulp
.task('testTask', done => {     console.log('hello!');     done(); });
/* Tasks */

/* Copy Libraries to their location */
gulp.task('copy-libraries',     done => {         srcPaths.lib.forEach(item => {             return gulp.src(item.src)                 .pipe(gulp.dest(item.dest));         });         done();     });
gulp
.task('clean-libraries',     done => {         srcPaths.lib.forEach(item => {             return gulp.src(item.dest)                 .pipe(gp_clean({ force: true }));         });         done();     }); /* TypeScript */ gulp.task("ts", done => {     return tsProject.src()         .pipe(tsProject())         .pipe(gulp.dest(path.resolve(basePath, 'src'))); }); gulp.task("ts_clean", done => {     return gulp.src(srcPaths.srcJs)         .pipe(gp_clean({ force: true })); }); /* JavaScript */ gulp.task('js', done => {     srcPaths.js.forEach(file => {         const b = browserify({             entries: file, // Only need initial file, browserify finds the deps             debug: true, // Enable sourcemaps             transform: [['babelify', { 'presets': ["es2015"] }]]         });         b.bundle()             .pipe(source(path.basename(file)))             .pipe(rename(path => {                 path.basename += ".min";                 path.extname = ".js";             }))             .pipe(buffer())             .pipe(sourcemaps.init({ loadMaps: true }))             .pipe(uglify())             .pipe(sourcemaps.write())             .pipe(gulp.dest(destPaths.js));         done();     }); }); gulp.task('js_clean', done => {     return gulp.src(path.resolve(destPaths.js, '**/*.js'), { read: false })         .pipe(gp_clean({ force: true })); }); /* Defaults */ gulp.task('cleanup', gulp.series(['clean-libraries', 'ts_clean', 'js_clean'])); gulp.task('default', gulp.series(['copy-libraries', 'ts', 'js']));

After we save our gulpfile, you should be able to open the Task Runner (View / Other Windows... / Task Runner Explorer) and run the default behavior.

Adding our Modal

With our project properly configured (Phew!), we can now move forward with the modal and ViewComponent implementation.

Since we have an Index page already, I added the modal to the bottom of the page.

@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<
div class="text-center">
    <h1 class="display-4">Welcome</h1>
    <p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
</div>

<
button type="button" data-bs-toggle="modal" class="btn btn-primary" data-bs-target="#happiest-dialog">See the Happiest Dialog on the Internet</button>


<!-- Dialog -->

<div class="modal" id="happiest-dialog" tabindex="-1">
    <div class="modal-dialog modal-lg">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">The Happiest Carousel</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
            </div>

            <div class="modal-body">
                <h4 class="text-center"><i class="fa fa-spinner fa-spin"></i> Loading... </h4>
            </div>

            <div class="modal-footer">
                <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
            </div>
        </div>
    </div>
</div>

@section
 Scripts
{
    <script src="~/js/dialogexample.min.js"></script>
}

We'll call our modal "happiest-dialog".

You're probably wondering why we're defining this here instead of making it entirely dynamic. There are two reasons why:

  1. Perceived Performance - You want the user to think this is a fast app. What better way to show something is working than to display a dialog box immediately with a loading indicator. The HTML is already there, just display it. It gives the user the illusion that something happened when they clicked on the link. Once the dialog appears, they receive a "loading..." indicator and the process finishes.
  2. Dynamic Data - Since the "frame" of the modal is there, all we do is make a call to retrieve the body of the modal. This could include parameters based on user input. Instead of creating everything in JavaScript, make a GET request with parameters to retrieve the ViewComponent and you're done.

This takes care of the HTML. Now on to the JavaScript.

Minimal JavaScript

The main TypeScript file we'll use in the project is the dialogexample.ts.

Our dialogexample code will grab the dialog element and attach an event listener to it when the modal is shown. Once we retrieve the data, we replace the HTML in the ".modal-body" with the content returned.

import { HappiestDialogService } from "./HappiestDialogService";
import { ready } from "./common";

class
 App {

   private modal: Element = document.getElementById('happiest-dialog');
    private service: HappiestDialogService = new HappiestDialogService();

   constructor() {
        this.modal.addEventListener('shown.bs.modal', () => {
            this.service.getHappiestComponent()
            .then((data) => {
                this.modal.getElementsByClassName("modal-body")[0].innerHTML = data;
            })
        })
    }
}

ready
(() => new App())

The ready() function is the equivalent to the $(function() {}); in jQuery to confirm we have the entire document loaded.

Creating the ViewComponent

Our ViewComponent doesn't contain a model since it's simple HTML added to the dialog box.

However, there is a trick to calling a ViewComponent from JavaScript which we'll get into in a minute.

For now, we need to create our ViewComponent based on the search path for Razor Pages.

Our server-side ViewComponent is called HappiestPlaceViewComponent and consists of displaying the carousel with four images.

/Pages/Shared/Components/HappiestPlace/Default.cshtml

<div id="carouselExampleIndicators" class="carousel slide" data-bs-ride="carousel">
    <div class="carousel-indicators">
        <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="0" class="active" aria-current="true" aria-label="Slide 1"></button>
        <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="1" aria-label="Slide 2"></button>
        <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="2" aria-label="Slide 3"></button>
        <button type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide-to="3" aria-label="Slide 4"></button>
    </div>
    <div class="carousel-inner">
        <div class="carousel-item active">
            <img src="/images/Photo0012.jpg" class="d-block w-100" alt="Magic Kingdom 2">
        </div>
        <div class="carousel-item">
            <img src="/images/Photo0003.jpg" class="d-block w-100" alt="Lego Store at Disney Springs">
        </div>
        <div class="carousel-item">
            <img src="/images/Photo0007.jpg" class="d-block w-100" alt="T-Rex Restaurant">
        </div>
        <div class="carousel-item">
            <img src="/images/Photo0011.jpg" class="d-block w-100" alt="Magic Kingdom 1">
        </div>
    </div>
    <button class="carousel-control-prev" type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide="prev">
        <span class="carousel-control-prev-icon" aria-hidden="true"></span>
        <span class="visually-hidden">Previous</span>
    </button>
    <button class="carousel-control-next" type="button" data-bs-target="#carouselExampleIndicators" data-bs-slide="next">
        <span class="carousel-control-next-icon" aria-hidden="true"></span>
        <span class="visually-hidden">Next</span>
    </button>
</div>

/Pages/Shared/Components/HappiestPlace/HappiestPlaceViewComponent.cs

using Microsoft.AspNetCore.Mvc;

namespace DialogExample.Pages.Shared.Components.HappiestPlace
{
    public class HappiestPlaceViewComponent : ViewComponent
    {
        public IViewComponentResult Invoke()
        {
            return View();
        }
    }
}

Now for the tricky part.

Since we're using ViewComponents with Razor Pages, we really don't have a way to make a call to a "controller" with Razor Pages from JavaScript. We're essentially calling a page. However, we can look at this another way.

With Razor Pages, when you have multiple buttons on a form, a button can contain a handler defined in the "code-behind" using a simple convention. The convention on Razor Pages is "?handler=On<verb><handlerName>".

In our case, we can create an OnGetHappiestPlaceContent() method to return our HappiestPlace ViewComponent.

/Pages/Index.cshtml.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;

namespace
 DialogExample.Pages
{
    public class IndexModel : PageModel
    {
        private readonly ILogger<IndexModel> _logger;

       public IndexModel(ILogger<IndexModel> logger)
        {
            _logger = logger;
        }

       public void OnGet()
        {

       }

       public IActionResult OnGetHappiestPlaceContent()
        {
            return ViewComponent("HappiestPlace");
        }
    }
}

We're now able to call our Index Razor Page with a handler of "?handler=HappiestPlaceContent". By convention, this will make a call to the OnGetHappiestPlaceContent method and return the ViewComponent.

/wwwroot/src/HappiestDialogService.ts

import { BaseService } from "./BaseService";

export
 class HappiestDialogService extends BaseService {

   private url: string = "?handler=HappiestPlaceContent";

   constructor() {
        super();
    }

   public getHappiestComponent() {

       const request = new Request(this.url,
            { method: "get" });

       return fetch(request)
            .then(this.validateResponse)
            .then(this.readResponseAsText);
    }
}

Once we return the HTML from our ViewComponent, we update the dialog box with our carousel control.

GitHub Source

Of course, the Github source is located here.

Conclusion

We can adjust this approach and create a number of different dialogs based on sections in the application. You could even pass in a different handler or simply use a ViewComponent name to call dynamic ViewComponents to populate the dialog box.

Building components is what development is all about and utilizing ViewComponents gives you flexibility and consistency throughout your application. 

How are you using ViewComponents in your application? Do you have a system for components? Post your comments below and let's discuss the approaches.