Ajax modals in Razor Pages using Boostrap

Razor Pages ajax modals with validation

This tutorial will show you how to create ajax powered Bootstrap modals in Razor Pages. You’ll learn how to dynamically load partial content, how to send/save your form via ajax and how to validate it using Razor Pages as a backend technology.

Final proof-of-concept source code is in this GitHub repository.

As a side note, if you want to see how to the same thing in ASP.NET Core then go to my other tutorial: ASP.NET Core ajax modals with validation using Bootstrap.

What we’re going to create

Razor Pages Ajax Modal - action plan

Create project

Let’s start off by creating new Visual Studio solution. I’m using Visual Studio Community 2019. Pick the “Create a new project” menu option, then select “ASP.NET Core Web Application” as shown below.

visual studio 2019 create new project panel
Visual Studio 2019 Create new web project

Follow along by clicking Next and then entering your solution and project name. Click Next once again, it will lead us to another panel in which we can select project type. Select “Web Application” to create Razor Pages project and hit Create.

Visual Studio 2019 create Razor Pages project panel
Visual Studio 2019 Create Razor Pages project

Project cleanup

We’re almost ready to go. Before we start we’re going to remove all extra stuff in default project. This step is not necessary, you can proceed with the tutorial if you wish.

Static resources are located in wwwroot directory, let me show you which one we need and which one we can remove.

First is the css directory, we’re not going to play with CSS, but since your’re probably going to need it at some point we’re going to keep the site.css file. By default it already contains some styles, you can safely clear the file.

Then we have the images directory. We’ll not need it at all, remove it altogether.

Next the js directory. We’ll be writing some custom JavaScript, leave it as it is now.

We’re left with lib directory, since we’re going to use CDN we can remove it.

Now, let’s clean up Pages directory. For this tutorial we’re only going to need following files:

  • Shared/_Layout.cshtml
  • _ViewImports.cshtml
  • _ViewStart.cshtml
  • Error.cshtml
  • Index.cshtml

Other page files can be safely removed.

Good, now we’re going to clean up _Layout.cshtml to include everything we’ll use:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - RazorPages AjaxModals</title>

    <link rel="stylesheet" 
          href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" 
          integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" 
          crossorigin="anonymous">
    <environment include="Development">
        <link rel="stylesheet" href="~/css/site.css" />
    </environment>
    <environment exclude="Development">
        <link rel="stylesheet" href="~/css/site.min.css" asp-append-version="true" />
    </environment>
</head>
<body>
    <div class="container pt-5">
        @RenderBody()
    </div>

    <script
            src="https://code.jquery.com/jquery-3.3.1.slim.min.js" 
            integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" 
            crossorigin="anonymous"></script>
    <script 
            src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" 
            integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" 
            crossorigin="anonymous"></script>
    <script 
            src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" 
            integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" 
            crossorigin="anonymous"></script>
    <environment include="Development">
        <script src="~/js/site.js" asp-append-version="true" defer></script>
    </environment>
    <environment exclude="Development">
        <script src="~/js/site.min.js" asp-append-version="true" defer></script>
    </environment>

    @RenderSection("Scripts", required: false)
</body>
</html>

And the last modification is Index.cshtml page, which you can clear. Now we’re ready to go

Model

Our modal will contain simple contact form with only three fields: first name, last name and email.

First thing we need to do is to create a model/class which with these fields. Create Models directory and then create a Contact.cs file:

public class Contact
{
    public string FirstName { get; set; }

    public string LastName { get; set; }

    public string Email { get; set; }
}

Modal contents

Let’s prepare a modal view, which will contain an “add contact” form. All the markup here is a valid Bootstrap modal, I’m not going to describe it here. If you want to know more about markup then please refer to Bootstrap documentation.

We’ll create separate view for modal, which we’ll later fetch and display via ajax request. Right click on Pages directory in Solution Explorer, choose Add/Razor Page, then select Razor Page and click Create. Make sure to uncheck “Generate PageModel class“, we only want the view. I named by file _ContactModalPartial.cshtml

Notice the use of html tag helpers which renders valid inputs. Some of the we’ll need later on, so don’t think about them too much right now.

// Pages/_ContactModalPartial.cshtml
@model Contact
<!-- Modal -->
<div class="modal fade" id="add-contact" tabindex="-1" role="dialog" aria-labelledby="addContactLabel" aria-hidden="true">
    <div class="modal-dialog" role="document">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title" id="addContactLabel">Add contact</h5>
                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                    <span aria-hidden="true">×</span>
                </button>
            </div>
            <div class="modal-body">
                <form asp-page-handler="ContactModalPartial">
                    <input name="IsValid" type="hidden" value="@ViewData.ModelState.IsValid.ToString()" />
                    <div class="form-group">
                        <label asp-for="FirstName"></label>
                        <input asp-for="FirstName" class="form-control" />
                        <span asp-validation-for="FirstName" class="text-danger"></span>
                    </div>
                    <div class="form-group">
                        <label asp-for="LastName"></label>
                        <input asp-for="LastName" class="form-control" />
                        <span asp-validation-for="LastName" class="text-danger"></span>
                    </div>
                    <div class="form-group">
                        <label asp-for="Email"></label>
                        <input asp-for="Email" class="form-control" />
                        <span asp-validation-for="Email" class="text-danger"></span>
                    </div>
                </form>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
                <button type="button" class="btn btn-primary" data-save="modal">Save</button>
            </div>
        </div>
    </div>
</div>

A few keypoints I want you to notice here are:

  • @model directive which points to our model/Contact class
  • full modal markup (powered by Bootstrap)
  • asp-page-handler form attribute, which points to Razor Pages handler (needed later to save modal contents)

Our modal is ready to be displayed.

Display (modal) partial view using ajax in Razor Pages

Let’s start by creating a button which will show our modal.

// Index.cshtml
<button type="button" class="btn btn-primary">
    Add contact
</button>

Right now our button does nothing. We want it to make a background ajax request to fetch modal contents and then show it. For this we’ll need some JavaScript. So, firstly we need to find a way to differentiate buttons which open modals as opposed to any other type of buttons. In this case we do that by data attribute. Add data-toggle attribute to your modal.

<button type="button" class="btn btn-primary" data-toggle="ajax-modal">
    Add contact
</button>

Then, we go to our site.js and add some code which handles button clicks:

$(function () {
    $('button[data-toggle="ajax-modal"]').click(function (event) {
        alert('button clicked');
    });
});

Good, once we click the button it shows an alert. Now comes the tricky part. We need to fetch modal HTML contents and render it as a modal.

Firstly, we want our modal (_ContactModalPartial.cshtml) to be rendered as HTML. Right now it’s a mix of HTML and Razor sitting somewhere in our project directory structure. Open your IndexModel class (the PageModel for Index page) and create new Razor Pages handler which will return modal. I have named it OnGetContactModalPartial, the OnGet is Razor Pages convention which matches GET requests and the ContactModalPartial is a name of the handler.

Since we want our handler to return only the modal and not the whole page (because whole page inlcudes all of _Layout.cshtml) we need to return PartialView. In Razor Pages we have to create PartialViewResult instance manually. We supply it with correct ViewName and initial ViewData which uses empty Contact model:

public class IndexModel : PageModel
{
    public void OnGet()
    {
        // this handler returns Index page
    }

    public PartialViewResult OnGetContactModalPartial()
    {
        // this handler returns _ContactModalPartial
        return new PartialViewResult
        {
            ViewName = "_ContactModalPartial",
            ViewData = new ViewDataDictionary<Contact>(ViewData, new Contact { })
        };
    }
}

If you want to check how this works you can run the project and execute your handler by going to /Index?handler=ContactModalPartial URL (e.g. https://localhost:44365/Index?handler=ContactModalPartial).

The last thing we need to do is to make our button and handler work together. Let’s go back to site.js file. We’re going to make and ajax request which will fetch modal and then we’ll render.

$(function () {
    $('button[data-toggle="ajax-modal"]').click(function (event) {
        // url to Razor Pages handler which returns modal HTML
        var url = '/Index?handler=ContactModalPartial';
        $.get(url).done(function (data) {
            // append HTML to document, find modal and show it
            $(document).append(data).find('.modal').modal('show');
        });
    });
});

Go ahead and try it.

Razor Pages Ajax Modal Screenshot
Razor Pages – ajax modal

Some improvements

You know how to show Bootstrap modal using Razor Pages and ajax request, but I want to show you two small, but important improvements to our solution.

You must’ve noticed that I hardcoded modal handler URL in site.js. It would be far better if we could move it from our JavaScript file back to HTML. Since we open the modal by clicking a button I think that the best place to put modal URL is the button itself. This way we have correlation between button and its handler.

Add data-url attribute to button, we use Url.Page helper to generate correct url:

// Index.cshtml
<!-- Button trigger modal -->
<button type="button" class="btn btn-primary" data-toggle="ajax-modal" data-url="@Url.Page("Index", "ContactModalPartial")">
    Add contact
</button>

Then modify JavaScript to get the url from button data-url attribute:

// site.js
$(function () {
    $('button[data-toggle="ajax-modal"]').click(function (event) {
        var url = $(this).data('url');
        $.get(url).done(function (data) {
            $(document).append(data).find('.modal').modal('show');
        });
    });
});

Second improvement which I want to make relates to what we do with modal HTML. Right now we append it directly to document body. If you open a modal and close it then its HTML code will still reside within body. What’s more if you keep clicking the button it will add more and more HTML code to our document. This also leads to a certain bug in our code. Can you spot it? Let me ask you a question. Since we stored all these modals directly in body which modal gets opened? We have multiple modal instances, but we want only one.

I’d like to show you how to prevent that kind of “pollution” and to fix buggy behavior. What I want to do now is to create a placeholder (and empty element) inside document body tag, which will serve as a place where we will put modal contents. I put in Index.cshtml, but you may want to consider putting it inside _Layout.cshtml if you want it work on all pages of your application.

// Index.cshtml
<!-- Modal placeholder -->
<div id="modal-placeholder"></div>

<!-- Button trigger modal -->
<button type="button" class="btn btn-primary" data-toggle="ajax-modal" data-url="@Url.Page("Index", "ContactModalPartial")">
    Add contact
</button>

All we need to do now is to modify JavaScript. We replace body with a placeholder:

// site.js
$(function () {
    var placeholderElement = $('#modal-placeholder');

    $('button[data-toggle="ajax-modal"]').click(function (event) {
        var url = $(this).data('url');
        $.get(url).done(function (data) {
            placeholderElement.html(data);
            placeholderElement.find('.modal').modal('show');
        });
    });
});

Saving modal form using ajax in Razor Pages

We know how to display modal, but it won’t do anything useful. Let me show you how to save modals contents. For this we’ll also use ajax, which means that when user clicks save button we’ll send background request to save data. After save we’ll close the modal.

We’re not going to set up a database or anything complicated in regards to saving modal form. What we’re going to do is to create a static list containing all contacts:

public class IndexModel : PageModel
{
    // this is where we put saved contacts
    private readonly static List<Contact> Contacts = new List<Contact>();

    public void OnGet()
    {
        // this handler returns Index page
    }

    public PartialViewResult OnGetContactModalPartial()
    {
        // this handler returns _ContactModalPartial
        return new PartialViewResult
        {
            ViewName = "_ContactModalPartial",
            ViewData = new ViewDataDictionary<Contact>(ViewData, new Contact { })
        };
    }
}

We’ll need another Razor Pages handler. We’ll use it to retrieve posted form data and save it. This time we use POST request instead of GET, so our handler starts with OnPost:

public class IndexModel : PageModel
{
    private readonly static List<Contact> Contacts = new List<Contact>();

    public void OnGet()
    {
        // this handler returns Index page
    }

    public PartialViewResult OnGetContactModalPartial()
    {
        // this handler returns _ContactModalPartial
        return new PartialViewResult
        {
            ViewName = "_ContactModalPartial",
            ViewData = new ViewDataDictionary<Contact>(ViewData, new Contact { })
        };
    }

    // "save" form data
    public void OnPostContactModalPartial(Contact model)
    {
        Contacts.Add(model);
    }
}

Now we need to take care of client side interaction. At the moment Save button is not working at all.

Firstly, we add click handler, so we can perform some actions after users click Save button. Notice selector we use to find the button, it uses the data-save attribute with value equal to modal:

$(function () {
    var placeholderElement = $('#modal-placeholder');

    $('button[data-toggle="ajax-modal"]').click(function (event) {
        var url = $(this).data('url');
        $.get(url).done(function (data) {
            placeholderElement.html(data);
            placeholderElement.find('.modal').modal('show');
        });
    });

    placeholderElement.on('click', '[data-save="modal"]', function (event) {
        // prevent default button click actions
        event.preventDefault();
        // TODO grab form data and send it
    });
});

Secondy, we find the form inside modal and get/prepare all the data before we send it. After that, we use serialize function to encode input fields in such a way that we can send it:

$(function () {
    var placeholderElement = $('#modal-placeholder');

    $('button[data-toggle="ajax-modal"]').click(function (event) {
        var url = $(this).data('url');
        $.get(url).done(function (data) {
            placeholderElement.html(data);
            placeholderElement.find('.modal').modal('show');
        });
    });

    placeholderElement.on('click', '[data-save="modal"]', function (event) {
        event.preventDefault();

        var form = $(this).parents('.modal').find('form');
        var dataToSend = form.serialize();
    });
});

Thirdly and lastly, we send the data and close the modal. Before we can send it however we need correct url to the Razor Pages handler created earlier. Fortunately, it’s already available to use inside form action attribute, we’ve done it beforehand:

// Pages/_ContactModalPartial.cshtml
<form asp-page-handler="ContactModalPartial">

Grab URL from form action attribute and post form:

$(function () {
    var placeholderElement = $('#modal-placeholder');

    $('button[data-toggle="ajax-modal"]').click(function (event) {
        var url = $(this).data('url');
        $.get(url).done(function (data) {
            placeholderElement.html(data);
            placeholderElement.find('.modal').modal('show');
        });
    });

    placeholderElement.on('click', '[data-save="modal"]', function (event) {
        event.preventDefault();

        var form = $(this).parents('.modal').find('form');
        var actionUrl = form.attr('action');
        var dataToSend = form.serialize();

        $.post(actionUrl, dataToSend).done(function (data) {
            placeholderElement.find('.modal').modal('hide');
        });
    });
});

If you want to verify that it works you’ll have to put a breakpoint inside OnPostContactModalPartial.

How to validate modal fields and display errors

We know to how to display modal and how to save it. Our final task is to validate it and display errors if needed.

I’ll show you how to validate fields using data annotations. For those who don’t know what are data annotations, they are special classes (attributes) used to decorate properties. Each one has it’s own logic used to verify property value against some criteria, for instance we can test if it’s empty or not. You can create your own data annotation attributes, but in this tutorial we’re going to use two built-in data annotations.

We want all of our properties to be required, so we decorate it with Required attribute. For the Email property we also want to make sure that it contains valid e-mail address so we also add EmailAddress attribute:

using System.ComponentModel.DataAnnotations;

namespace RazorPagesAjaxModals.Models
{
    public class Contact
    {
        [Required]
        public string FirstName { get; set; }

        [Required]
        public string LastName { get; set; }

        [Required]
        [EmailAddress]
        public string Email { get; set; }
    }
}

Validation is on, but regardless of what we put in our form it still gets saved. As a result, we need to modify OnPostContactModalPartial handler to save contact only when validation returned no errors. To do this we check the value of ModelState.IsValid:

public void OnPostContactModalPartial(Contact model)
{
    if (ModelState.IsValid)
    {
        Contacts.Add(model);
    }
}

Now we save only correct data, however users won’t know if there were any errors, because we don’t display them.

In order to display errors we’ll make OnPostContactModalPartial return PartialView. It’s the same view we use in OnGetContactModalPartial. After post ModelState contains validation errors, which we can access in view file by using HTML tag helpers. In fact we already use them, let’s review _ContactModalPartial.cshtml again to see which parts are used to display error messages:

(...)
<span asp-validation-for="FirstName" class="text-danger"></span>
(...)
<span asp-validation-for="LastName" class="text-danger"></span>
(...)
<span asp-validation-for="Email" class="text-danger"></span>

We did this beforehand, so all we need to do now is to return new HTML, which has these errors rendered and then put this HTML inside modal window so users can see it.

public PartialViewResult OnPostContactModalPartial(Contact model)
{
    if (ModelState.IsValid)
    {
        Contacts.Add(model);
    }

    return new PartialViewResult
    {
        ViewName = "_ContactModalPartial",
        ViewData = new ViewDataDictionary<Contact>(ViewData, model)
    };
}

There is one important difference here. We do not create new contact instance in ViewDataDictionary, but instead we put the data we received.

placeholderElement.on('click', '[data-save="modal"]', function (event) {
    event.preventDefault();

    var form = $(this).parents('.modal').find('form');
    var actionUrl = form.attr('action');
    var dataToSend = form.serialize();

    $.post(actionUrl, dataToSend).done(function (data) {
        // data is the rendered _ContactModalPartial
        var newBody = $('.modal-body', data);
        // replace modal contents with new form
        placeholderElement.find('.modal-body').replaceWith(newBody);
    });
});
Razor Pages – validated modal

Close modal when form was valid

We made it validate data, but when everything is correct modal won’t close. We need to find a way to mark a modal as valid (correctly saved) or invalid (containing validation errors).

For this we can also use ModelState.IsValid. We can put it inside form, as a hidden input field. In fact it’s already there:

(...)
<form asp-page-handler="ContactModalPartial">
    <input name="IsValid" type="hidden" value="@ViewData.ModelState.IsValid.ToString()" />
(...)

Let’s modify JavaScript to close modal only when ModelState.IsValid is true:

$.post(actionUrl, dataToSend).done(function (data) {
    var newBody = $('.modal-body', data);
    placeholderElement.find('.modal-body').replaceWith(newBody);

    var isValid = newBody.find('[name="IsValid"]').val() == 'True';
    if (isValid) {
        placeholderElement.find('.modal').modal('hide');
    }
});

Congratulations for making it this far. We’re done.