avatarM. Ramadhan

Summary

The provided web content outlines the implementation of CRUD operations for 1:N and M:N relationships in a Blazor Server application, focusing on creating master-detail pages for a private library database with practical examples and code snippets.

Abstract

The article is the seventh in a series covering the Blazor Server Project, aimed at teaching developers how to build practical applications through sample projects. It discusses the implementation of CRUD operations for one-to-many (1:N) and many-to-many (M:N) relationships, specifically for a private library database. The author guides readers through the creation of two master-detail pages: one for displaying books by publisher (1:N relationship) and another for books by author (M:N relationship). The article includes detailed instructions on setting up the project, creating the necessary components, and implementing dynamic navigation using parameterized routes. It also provides code examples for sorting, pagination, and handling CRUD operations, including deletion confirmation dialogs. The content is designed for readers with some experience in C#, HTML, CSS, and SQL, and it builds upon concepts introduced in previous articles in the series.

Opinions

  • The author emphasizes the practical approach to learning Blazor Server application development by working on sample projects.
  • The use of master-detail pages is presented as an effective way to display and manage related data in a user-friendly manner.
  • The article assumes that readers have a foundational understanding of web development technologies such as C#, HTML, CSS, and SQL, indicating that the content is targeted at intermediate-level developers.
  • By providing step-by-step instructions and code snippets, the author demonstrates a commitment to making the learning process as clear and straightforward as possible.
  • The inclusion of dynamic navigation and parameterized routes suggests that the author values modern web development practices that enhance user experience and application maintainability.
  • The author's approach to handling CRUD operations, including the use of confirmation dialogs for deletion, indicates an emphasis on user interface considerations and data integrity.

Blazor Server Project #7: Practical Guide to Make Master-Detail Page

Implementation of CRUD operation for 1:N (one-to-many) & M:N (many-to-many) relationship and dynamic navigation using parameterized route

Table of Contents

OverviewImplementing Master-Detail PageHow It WorksSummaryReferences

Photo by Daniel on Unsplash

This article is the seventh in a series covering the Blazor Server Project: (1) How to create a CRUD operation using Dapper (2) Building a dropdown list involves a 1:N relationship (3) How to implement a checkbox list involving an M:N relationships (4) Understanding URL routing and navigation (5) Creating and using page layout (6) How to create a reusable modal dialog component (7) Practical guide to making a master-detail page (8) Master-detail page using dynamic query (9) How to avoid SQL injection attacks (10) Hiding/showing HTML elements (11) Migrate to ASP.NET Core 6.0 (12) Installing ASP.NET Core Identity (13) Integrating Identity tables into the existing project database (14) Authentication and authorization (15) Role-based authorization (16) How to implement Google Authentication (17) Facebook Authentication and Authorization (18) How to configure Twitter Authentication (19) How to Customize Password Policy (20) Account Lockout Policy

These articles are for anyone who wants to learn how to build Blazor Server applications in a practical approach through some sample projects. It will be straightforward if you have some experience with C#, HTML, CSS, and SQL.

All of the following discusses base on the source code in the previous article, Blazor Server Project #6. Please download the source code via the link below.

Overview

CRUD (Create, Read, Update, Delete) are features that must be present in an application that uses a database. In the previous articles, we discussed how to implement CRUD operations involving: (1) only one table (2) two tables with N:1 (many to one) relationships (3) three tables with M:N (many-to-many) relationships.

The application that had been built uses a database based on the following ERD.

Figure 1 ERD of a private library

This time we’ll cover how to create two master-details pages:

  • Booklist per publisher as an implementation of the 1:N relationship, Publisher-publishes-Book.
  • Booklist per author as an implementation of the M:N relationship, Author-writes-Book.

Booklist per publisher

Figure 2 Master-detail of booklist per publisher
  • By default, the option in the dropdown list is [All]; all existing books are shown in the list.
  • You can display a booklist of a specific publisher by selecting a publisher from the dropdown list.

Booklist per author

First version

Figure 3 First version of the booklist per author

The two books in the list above feature only one author, whereas two authors wrote both. The M:N relationship is not depicted in the figure above.

Second version

Figure 4 Second version of the booklist per author

The second version describes the M:N relationship more clearly. An author can write many books; conversely, one book can be written by many authors. So we will create this one.

Implementing Master-Detail Pages

(1) Opening the BookApp project

  • Right-click the file BookApp.sln. It’s one of the files that you downloaded before.
Figure 5 Opening BookApp Application
  • Click Open context menu to open BookApp application.

(2) Creating BookDB database

(a) Opening the SQL Server Object Explorer pane

Figure 6 Opening SQL Server Object Explorer
  • Click the menu: View|SQL Server Object Explorer
  • Look into theSQL Server Object Explorer pane on the left. If there is the BookDB database, go to step (3). Otherwise, do the following step.

(b) Opening and executing the BookDB.sql file

Figure 7 Opening and executing the BookDB.sql file
  • In the Solution Explorer pane, click BookDB.sql to open the SQL script file.
  • Click the button to execute the whole of the SQL scripts in the BookDB.sql file.
  • The following similar screen appears.
Figure 8 Connecting server
  • Choose your server, click the Connect button.
Figure 9 Refreshing to show BookDB database in the list
  • In the SQL Server Object Explorer pane, right-click Databases, then click Refresh.
Figure 10 The BookDB database objects

(3) Adding interfaces and methods

  • Add the following interfaces into IBookService.cs file
Task<List<BookAuPub>> ListPerPub(int skip,
                                 int take,
                                 string orderBy,
                                 string direction,
                                 int idPub);
         
Task<List<BookAuPub>> ListPerAuthor(int skip,
                                    int take,
                                    string orderBy,
                                    string direction,
                                    int idAuthor);
         
Task<int> CountBookPerPub(int idPub);
Task<int> CountBookPerAuthor(int idAuthor);
  • Add the following code into BookService.cs file.
public Task<List<BookAuPub>> ListPerPub(int skip, int take,
       string orderBy, string direction = "DESC", int idPub = 0)
{
   var books = Task.FromResult(_dapperService.
       GetAll<BookAuPub>($"SELECT B.*, FName + ' ' + LName " +
       $"AuthorName, P.Name PubName FROM Book B LEFT OUTER JOIN" +
       $" Publisher P ON B.PubId=P.Id LEFT OUTER JOIN " +
       $"BookAuthor BA ON B.ISBN = BA.ISBN LEFT OUTER JOIN " +
       $"Author A ON BA.AuthorId = A.Id " +
       $"WHERE PubId = {idPub} ORDER BY {orderBy} " +
       $"{direction} OFFSET {skip} ROWS FETCH NEXT {take} " +
       $"ROWS ONLY;", null, commandType: CommandType.Text));
       return books;
}
public Task<List<BookAuPub>> ListPerAuthor(int skip, int take,
       string orderBy, string direction = "DESC", int idAuthor = 0)
{
   var books = Task.FromResult(_dapperService.
       GetAll<BookAuPub>($"SELECT B.*, FName + ' ' + LName " +
       $"AuthorName, P.Name PubName FROM Book B LEFT OUTER JOIN" +
       $" Publisher P ON B.PubId=P.Id LEFT OUTER JOIN " +
       $"BookAuthor BA ON B.ISBN = BA.ISBN LEFT OUTER JOIN " +
       $"Author A ON BA.AuthorId = A.Id WHERE B.ISBN IN (" +
       $"SELECT ISBN From BookAuthor WHERE AuthorId={idAuthor}" +
       $") ORDER BY {orderBy} {direction} OFFSET {skip} " +
       $"ROWS FETCH NEXT {take} ROWS ONLY;", null, 
       commandType: CommandType.Text));
       return books;
}
public Task<int> CountBookPerPub(int idPub)
{
   var totBook = Task.FromResult(_dapperService.Get<int>
       ($"select COUNT(*) from [Book] " +
       "WHERE PubId = {idPub}", null,
       commandType: CommandType.Text));
       return totBook;
}
public Task<int> CountBookPerAuthor(int idAuthor)
{
   var totBook = Task.FromResult(_dapperService.Get<int>
       ($"SELECT COUNT(*) FROM Book B INNER JOIN BookAuthor BA" +
       $" ON B.ISBN = BA.ISBN WHERE AuthorId = {idAuthor}", null,
       commandType: CommandType.Text));
       return totBook;
}
  • The ListPerPub() method retrieves booklist published by a publisher.
  • The ListPerAuthor() method retrieves a booklist written by an author.
  • The CountBookPerPub() method counts the number of books published by a publisher.
  • The CountBookPerAuthor() method counts the number of books written by an author.

(4) Creating the ListBookPerPub.razor component file

Figure 11 Selected menus to create the Razor component file
  • In the Solution Explorer pane, right-click the Page folder.
  • Select submenu: Add|Razor Component...
Figure 12 Creating ListBookPerPub.razor component file
  • Click Razor Component.
  • In the Name text box, type ListBookPerPub.razor as the component filename.
  • Click the Add button.
Figure 13 ListBookPerPub.razor file in the Page folder
  • Click ListBookPerPub.razor to open the file.

Base on Figure 4, the master part is the publisher, and the detail is the list of books. Please copy the code below and paste it into the ListBookPerPub.razor file.

ListBookPerPub.razor

@page "/listBookPerPub"
@inject IBookService bookService
@inject IPublisherService publisherService
<link href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
<h3>
   Booklist per publisher
</h3>
<style>
   .sort-th {
      cursor: pointer;
   }
   .fa {
      float: right;
   }
   .btn-custom {
      color: black;
      float: left;
      padding: 8px 16px;
      text-decoration: none;
      transition: background-color .3s;
      border: 2px solid #000;
      margin: 0px 5px 0px 5px;
   }
</style>
<form>
   <label for="Publisher">Publisher:</label>
   <select for="Publisher" @bind="IdPubFilter"
           @bind:event="onchange"> 
      <option value=@All>[ALL]</option>
      @foreach (var publisher in publishers)
      {
         <option value="@publisher.Id">@publisher.Name</option>
      }
   </select>&emsp;
   <label for="City">City:</label>
   <input for="City" @bind="@publisher.City" disabled/>&emsp;
   <label for="Country">Country:</label>
   <input for="Country" @bind="@publisher.Country" disabled/>
</form>
<br />
@if (books == null)
{
   <p><em>Loading...</em></p>
}
else
{
   <table class="table table-bordered table-hover">
      <thead>
         <tr>
            <th class="sort-th"
                @onclick="@(() => SortTable("ISBN"))">
                I S B N
               <span class="fa @(SetSortIcon("ISBN"))"></span>
            </th>
            <th class="sort-th"
                @onclick="@(() => SortTable("Title"))">
               T i t l e
               <span class="fa @(SetSortIcon("Title"))"></span>
            </th>
            <th class="sort-th"
                @onclick="@(() => SortTable("AuthorName"))">
               Author
               <span class="fa @(SetSortIcon("AuthorName"))"></span>
            </th>
            <th class="sort-th"
                @onclick="@(() => SortTable("PubYear"))">
               Pub.<br />
               Year
               <span class="fa @(SetSortIcon("PubYear"))"></span>
            </th>
            <th class="sort-th"
                @onclick="@(() => SortTable("PurchDate"))">
               Purchase<br />Date
               <span class="fa @(SetSortIcon("PurchDate"))"></span>
            </th>
            <th class="sort-th"
                @onclick="@(() => SortTable("PubName"))">
               Publisher
               <span class="fa @(SetSortIcon("PubName"))"></span>
            </th>
            <th>Action</th>
         </tr>
      </thead>
      <tbody>
         @if (books == null || books.Count == 0)
         {
            <tr>
               <td colspan="3">
                  No Records to display
               </td>
            </tr>
         }
         else
         {
            long prevISBN = 0;
            foreach (var book in books)
            {
               if (@book.ISBN != prevISBN)
               {
                  <tr>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        @book.ISBN
                     </td>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        @book.Title
                     </td>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        @book.AuthorName
                     </td>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        @book.PubYear
                     </td>
                     <td>
                        <hr style="padding:0px;  margin:0px;">
                        @book.PurchDate.ToShortDateString()
                     </td>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        @book.PubName
                     </td>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        <a class="btn btn-primary"
                           href='/editBook/@name/@book.ISBN'>
                           &#8194;Edit&#8194;
                        </a>&#8194;
                        <a class="btn btn-warning" @onclick="() =>
                           OpenDialog((long)book.ISBN,book.Title)">
                        Delete</a>
                     </td>
                  </tr>
               }
               else
               {
                  <tr>
                     <td></td>
                     <td></td>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        @book.AuthorName
                     </td>
                  </tr>
               }
               prevISBN = (long)@book.ISBN;
            }
         }
      </tbody>
   </table>
   <div class="pagination">
      <button class="btn btn-custom" @onclick=@(async ()=>
              await NavigateToPage("previous"))>◀</button>
   @for (int i = startPage; i <= endPage; i++)
      {
         var currentPage = i;
         <button class="btn btn-custom pagebutton
                 @(currentPage==curPage?"btn-info":"")"
                 @onclick=@(async () => 
                 await refreshRecords(currentPage))>@currentPage
         </button>
      }
      <button class="btn btn-custom" @onclick=@(async ()=>
              await NavigateToPage("next"))>▶</button>&emsp;
      <button class="btn btn-primary"
              onclick="window.location.href='/addBook/@name'">
         Add new book
      </button>
   </div>
}
@if (DialogIsOpen)
{
   <Dialog Caption="Delete a book"
           Message="@message"
           OnClose="@OnDialogClose"
           Type="Dialog.Category.DeleteNot">
   </Dialog>
}
@code {
   private const string name = "listBookPerPub";
   Publisher publisher = new Publisher();
   List<BookAuPub> books;
   List<Publisher> publishers = new List<Publisher>();
   private long idBook;
   private string message;
   private bool DialogIsOpen = false;
   const int All = -1;
   private int idPubFilter = All;
   private int IdPubFilter
   {
      get { return idPubFilter; }
      set { idPubFilter = value; this.InitializedAsync().Wait(); }
   }
   #region Pagination
   int totalPages;
   int totalRecords;
   int curPage;
   int pagerSize;
   int pageSize;
   int startPage;
   int endPage;
   string sortColumnName = "PurchDate";
   string sortDir = "DESC";
   #endregion
   protected override async Task OnInitializedAsync()
   {
      publishers = await publisherService.FetchAll();
      await InitializedAsync();
   }
   protected async Task InitializedAsync()
   {
      //display total page buttons
      pagerSize = 3;
      pageSize = 5;
      curPage = 1;
      endPage = 0;
      if (idPubFilter == All)
      {
         books = await bookService.ListAll((curPage - 1) * pageSize,
                 pageSize, sortColumnName, sortDir, "");
         totalRecords = await bookService.Count("");
         publisher.City = "";
         publisher.Country = "";
      }
      else
      {
         publisher = await publisherService.ReadByPk(idPubFilter);
         books = await bookService.ListPerPub((curPage - 1) *
         pageSize, pageSize, sortColumnName, sortDir,idPubFilter);
         totalRecords = await bookService.CountResult(idPubFilter);
      }
      totalPages = (int)Math.Ceiling
                   (totalRecords / (decimal)pageSize);
      SetPagerSize("forward");
   }
   private void OpenDialog(long isbn, string title)
   {
      DialogIsOpen = true;
      idBook = isbn;
      message = "Do you want to delete the book \"" + title + "\"?";
   }
   private async Task OnDialogClose(bool isOk)
   {
      if (isOk)
      {
         await bookService.Delete(idBook);
         if (idPubFilter == All)
         {
            books = await bookService.ListAll((curPage - 1) *
               pageSize, pageSize, sortColumnName, sortDir, "");
         }
         else
         {
            books = await bookService.ListPerPub((curPage - 1) *
            pageSize, pageSize, sortColumnName, sortDir,
            idPubFilter);
         }
      }
      DialogIsOpen = false;
   }
   private bool isSortedAscending;
   private string activeSortColumn;
   private async Task<List<BookAuPub>> SortRecords
           (string columnName, string dir)
   {
      if (idPubFilter == All)
      {
         return await bookService.ListAll((curPage - 1) *
         pageSize, pageSize, columnName, dir, "");
      }
      else
      {
         return await bookService.ListPerPub((curPage - 1) *
         pageSize, pageSize, columnName, dir, idPubFilter);
      }
   }
   private async Task SortTable(string columnName)
   {
      if (columnName != activeSortColumn)
      {
         books = await SortRecords(columnName, "ASC");
         isSortedAscending = true;
         activeSortColumn = columnName;
      }
      else
      {
         if (isSortedAscending)
         {
            books = await SortRecords(columnName, "DESC");
         }
         else
         {
            books = await SortRecords(columnName, "ASC");
         }
         isSortedAscending = !isSortedAscending;
      }
      sortColumnName = columnName;
      sortDir = isSortedAscending ? "ASC" : "DESC";
   }
   private string SetSortIcon(string columnName)
   {
      if (activeSortColumn != columnName)
      {
         return string.Empty;
      }
      if (isSortedAscending)
      {
         return "fa-sort-up";
      }
      else
      {
         return "fa-sort-down";
      }
   }
   public async Task refreshRecords(int currentPage)
   {
      if (idPubFilter == All)
      {
         books = await bookService.ListAll((curPage - 1) * 
         pageSize, pageSize, sortColumnName, sortDir, "");
      }
      else
      {
         books = await bookService.ListPerPub((curPage - 1) *
         pageSize, pageSize, sortColumnName, sortDir, idPubFilter);
      }
      curPage = currentPage;
      this.StateHasChanged();
   }
   public void SetPagerSize(string direction)
   {
      if (direction == "forward" && endPage < totalPages)
      {
         startPage = endPage + 1;
         if (endPage + pagerSize < totalPages)
         {
            endPage = startPage + pagerSize - 1;
         }
         else
         {
            endPage = totalPages;
         }
         this.StateHasChanged();
      }
      else if (direction == "back" && startPage > 1)
      {
         endPage = startPage - 1;
         startPage = startPage - pagerSize;
      }
      else
      {
         startPage = 1;
         endPage = totalPages;
      }
   }
   public async Task NavigateToPage(string direction)
   {
      if (direction == "next")
      {
         if (curPage < totalPages)
         {
            if (curPage == endPage)
            {
               SetPagerSize("forward");
            }
            curPage += 1;
         }
      }
      else if (direction == "previous")
      {
         if (curPage > 1)
         {
            if (curPage == startPage)
            {
               SetPagerSize("back");
            }
            curPage -= 1;
         }
      }
      await refreshRecords(curPage);
   }
}

(5) Creating the ListBookPerAuthor.razor component file.

  • Same as in step (4), create a component file named ListBookPerAuthor.razor.
  • Base on Figure 5 above, the master is the author, and the detail is the list of books. Please copy the code below and paste it into the ListBookPerAuthor.razor file.

ListBookPerAuthor.razor

@page "/listBookPerAuthor"
@inject IBookService bookService
@inject IAuthorService authorService
<link href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
<h3>
   Booklist per Author
</h3>
<style>
   .sort-th {
      cursor: pointer;
   }
   .fa {
      float: right;
   }
   .btn-custom {
      color: black;
      float: left;
      padding: 8px 16px;
      text-decoration: none;
      transition: background-color .3s;
      border: 2px solid #000;
      margin: 0px 5px 0px 5px;
   }
</style>
<form>
   <label for="Author">Author:</label>
   <select for="Author" @bind="IdAuthorFilter"
           @bind:event="onchange">
      <option value=@All>[ALL]</option>
      @foreach (var author in authors)
      {
         <option value="@author.Id">
            @author.FName @author.LName
         </option>
      }
   </select>&emsp;
   <label for="Phone">Phone:</label>
   <input for="Phone" @bind="@author.Phone" disabled />
</form>
<br />
@if (books == null)
{
   <p><em>Loading...</em></p>
}
else
{
   <table class="table table-bordered table-hover">
      <thead>
         <tr>
            <th class="sort-th"
                @onclick="@(() => SortTable("ISBN"))">
               I S B N
               <span class="fa @(SetSortIcon("ISBN"))"></span>
            </th>
            <th class="sort-th"
                @onclick="@(() => SortTable("Title"))">
               T i t l e
               <span class="fa @(SetSortIcon("Title"))"></span>
            </th>
            <th class="sort-th"
                @onclick="@(() => SortTable("AuthorName"))">
               Author
               <span class="fa @(SetSortIcon("AuthorName"))">
               </span>
            </th>
            <th class="sort-th"
                @onclick="@(() => SortTable("PubYear"))">
               Pub.<br />Year
               <span class="fa @(SetSortIcon("PubYear"))"></span>
            </th>
            <th class="sort-th"
                @onclick="@(() => SortTable("PurchDate"))">
               Purchase<br />Date<span class=
               "fa @(SetSortIcon("PurchDate"))"></span>
            </th>
            <th class="sort-th"
                @onclick="@(() => SortTable("PubName"))">
               Publisher<span class=
               "fa @(SetSortIcon("PubName"))"></span>
            </th>
            <th>Action</th>
         </tr>
      </thead>
      <tbody>
         @if (books == null || books.Count == 0)
         {
            <tr>
               <td colspan="3">
                  No Records to display
               </td>
            </tr>
         }
         else
         {
            long prevISBN = 0;
            foreach (var book in books)
            {
               if (@book.ISBN != prevISBN)
               {
                  <tr>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        @book.ISBN
                     </td>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        @book.Title
                     </td>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        @book.AuthorName
                     </td>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        @book.PubYear
                     </td>
                     <td>
                        <hr style="padding:0px;  margin:0px;">
                        @book.PurchDate.ToShortDateString()
                     </td>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        @book.PubName
                     </td>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        <a class="btn btn-primary"
                           href='/editBook/@name/@book.ISBN'>
                           &#8194;Edit&#8194;
                        </a>&#8194;
                        <a class="btn btn-warning" @onclick="() =>
                           OpenDialog((long)book.ISBN,book.Title)">
                           Delete
                        </a>
                     </td>
                  </tr>
               }
               else
               {
                  <tr>
                     <td></td>
                     <td></td>
                     <td>
                        <hr style="padding:0px; margin:0px;">
                        @book.AuthorName
                     </td>
                  </tr>
               }
               prevISBN = (long)@book.ISBN;
            }
         }
      </tbody>
   </table>
   <div class="pagination">
      <button class="btn btn-custom" @onclick=@(async ()=>
              await NavigateToPage("previous"))>◀</button>
   @for (int i = startPage; i <= endPage; i++)
      {
         var currentPage = i;
         <button class="btn btn-custom pagebutton
            @(currentPage==curPage?"btn-info":"")"
            @onclick=@(async () =>
            await refreshRecords(currentPage))>@currentPage
         </button>
      }
      <button class="btn btn-custom" @onclick=@(async ()=>
              await NavigateToPage("next"))>▶</button>&emsp;
      <button class="btn btn-primary"
         onclick="window.location.href='/addBook/@name'">
         Add new book
      </button>
   </div>
}
@if (DialogIsOpen)
{
   <Dialog Caption="Delete a book"
           Message="@message"
           OnClose="@OnDialogClose"
           Type="Dialog.Category.DeleteNot">
   </Dialog>
}
@code {
   private const string name = "listBookPerAuthor";
   Author author = new Author();
   List<BookAuPub> books;
   List<Author> authors = new List<Author>();
   private long idBook;
   private string message;
   private bool DialogIsOpen = false;
   const int All = -1;
   private int idAuthorFilter = All;
   private int IdAuthorFilter
   {
      get { return idAuthorFilter; }
      set { idAuthorFilter = value; this.InitializedAsync().Wait(); }
   }
   #region Pagination
   int totalPages;
   int totalRecords;
   int curPage;
   int pagerSize;
   int pageSize;
   int startPage;
   int endPage;
   string sortColumnName = "PurchDate";
   string sortDir = "DESC";
   #endregion
   protected override async Task OnInitializedAsync()
   {
      authors = await authorService.FetchAll();
      await InitializedAsync();
   }
   protected async Task InitializedAsync()
   {
      //display total page buttons
      pagerSize = 3;
      pageSize = 5;
      curPage = 1;
      endPage = 0;
      if (idAuthorFilter == All)
      {
         books = await bookService.ListAll((curPage - 1) * pageSize,
                 pageSize, sortColumnName, sortDir, "");
         totalRecords = await bookService.Count("");
         author.Phone = "";
      }
      else
      {
         author = await authorService.ReadByPk(idAuthorFilter);
         books = await bookService.ListPerAuthor((curPage - 1) *
            pageSize, pageSize, sortColumnName, sortDir,
            idAuthorFilter);
         totalRecords = await bookService.CountResult
            (idAuthorFilter);
      }
      totalPages = (int)Math.Ceiling
                   (totalRecords / (decimal)pageSize);
      SetPagerSize("forward");
   }
   private void OpenDialog(long isbn, string title)
   {
      DialogIsOpen = true;
      idBook = isbn;
      message = "Do you want to delete the book \"" + title + "\"?";
   }
   private async Task OnDialogClose(bool isOk)
   {
      if (isOk)
      {
         await bookService.Delete(idBook);
         if (idAuthorFilter == All)
         {
            books = await bookService.ListAll((curPage - 1) *
            pageSize, pageSize, sortColumnName, sortDir, "");
         }
         else
         {
            books = await bookService.ListPerAuthor((curPage - 1) *
            pageSize, pageSize, sortColumnName, sortDir,
            idAuthorFilter);
         }
      }
      DialogIsOpen = false;
   }
   private bool isSortedAscending;
   private string activeSortColumn;
   private async Task<List<BookAuPub>> SortRecords
           (string columnName, string dir)
   {
      if (idAuthorFilter == All)
      {
         return await bookService.ListAll((curPage - 1) *
         pageSize, pageSize, columnName, dir, "");
      }
      else
      {
         return await bookService.ListPerAuthor((curPage - 1) *
         pageSize, pageSize, columnName, dir, idAuthorFilter);
      }
   }
   private async Task SortTable(string columnName)
   {
      if (columnName != activeSortColumn)
      {
         books = await SortRecords(columnName, "ASC");
         isSortedAscending = true;
         activeSortColumn = columnName;
      }
      else
      {
         if (isSortedAscending)
         {
            books = await SortRecords(columnName, "DESC");
         }
         else
         {
            books = await SortRecords(columnName, "ASC");
         }
         isSortedAscending = !isSortedAscending;
      }
      sortColumnName = columnName;
      sortDir = isSortedAscending ? "ASC" : "DESC";
   }
   private string SetSortIcon(string columnName)
   {
      if (activeSortColumn != columnName)
      {
         return string.Empty;
      }
      if (isSortedAscending)
      {
         return "fa-sort-up";
      }
      else
      {
         return "fa-sort-down";
      }
   }
   public async Task refreshRecords(int currentPage)
   {
      if (idAuthorFilter == All)
      {
         books = await bookService.ListAll((curPage - 1) * pageSize,
              pageSize, sortColumnName, sortDir, "");
      }
      else
      {
         books = await bookService.ListPerAuthor((curPage - 1) *
           pageSize, pageSize, sortColumnName, sortDir,
           idAuthorFilter);
      }
      curPage = currentPage;
      this.StateHasChanged();
   }
   public void SetPagerSize(string direction)
   {
      if (direction == "forward" && endPage < totalPages)
      {
         startPage = endPage + 1;
         if (endPage + pagerSize < totalPages)
         {
            endPage = startPage + pagerSize - 1;
         }
         else
         {
            endPage = totalPages;
         }
         this.StateHasChanged();
      }
      else if (direction == "back" && startPage > 1)
      {
         endPage = startPage - 1;
         startPage = startPage - pagerSize;
      }
      else
      {
         startPage = 1;
         endPage = totalPages;
      }
   }
   public async Task NavigateToPage(string direction)
   {
      if (direction == "next")
      {
         if (curPage < totalPages)
         {
            if (curPage == endPage)
            {
               SetPagerSize("forward");
            }
            curPage += 1;
         }
      }
      else if (direction == "previous")
      {
         if (curPage > 1)
         {
            if (curPage == startPage)
            {
               SetPagerSize("back");
            }
            curPage -= 1;
         }
      }
      await refreshRecords(curPage);
   }
}

(6) Modifying routes and their navigations

In the previous project, the AddBook.razor and EditBook.razor files are only called by ListBook.razor; see the following figure.

Figure 14 AddBook.razor and EditBook.razor are called by the only single component

The navigations use a static route, as shown in the following figure.

Figure 15 The navigations using the static route

In this project, the pages — AddBook.razor and EditBook.razor — are called by ListBook.razor, ListBookPerPub.razor, and ListBookPerAuthor.razor.

Figure 16 AddBook.razor and EditBook.razor are called by the three components

So we must modify routes and navigations in both components files, AddBook.razor and EditBook.razor. This time, as shown in the code below, we use parameterized routes to navigate dynamically.

AddBook.razor

@page "/addBook/{PrevPage}"
@inject IBookService bookService
@inject IPublisherService publisherService
@inject IBookAuthorService bookAuthorService
@inject Microsoft.AspNetCore.Components.NavigationManager navigationManager
<h3>
   Add Book
</h3>
<form>
   <table width="100%" cellpadding="0" cellspacing="0" border="0">
      <tbody>
         <tr>
            <td><label for="ISBN" class="control-label">ISBN</label>
            </td>
            <td><input for="ISBN" class="form-control" 
                @bind="@book.ISBN" /></td>
         </tr>
         <tr>
            <td><label for="Title" class="control-label">
                Title</label></td>
            <td><input for="Title" class="form-control" 
                @bind="@book.Title" /></td>
         </tr>
         @if (@hasAdded)
         { 
            <tr>
               <td><label for="Authors" class="control-label">
                   Authors</label></td>
               <td>
                  <label style="width: 95px;">
                     <u><i><b>Sequence</b></i></u>
                  </label>
                  <label><u><i><b>Full Name</b></i></u></label>
                  <br />
                  @foreach (var bookAuthorName in bookAuthorNames)
                  {
                     <input type="number"
                     @bind="@bookAuthorName.AuthorOrd"
                     @bind:event="oninput"
                     style="width: 75px;" min="0" max="5" />
                     @if (@bookAuthorName.ISBN > 0)
                     {
                        <input name="AreChecked" type="checkbox"
                        value="@bookAuthorName.AuthorId" checked
                        @onchange="eventArgs => { CheckChanged
                        (bookAuthorName, eventArgs.Value);}" /> 
                     }
                     else
                     {
                        <input name="AreChecked" type="checkbox"
                        value="@bookAuthorName.AuthorId"
                        @onchange="eventArgs => { CheckChanged
                        (bookAuthorName, eventArgs.Value);}" />
                     }
                     <label for="AuthorName" style="width: 150px;">
                        @bookAuthorName.AuthorName
                     </label><br />
                  }
               </td>
               <td>
                  &#8194;
                  <a class="btn btn-primary"
                          href='/addAuthor/@book.ISBN'>
                     &#8194;Add author&#8194;
                  </a><br />
               </td>
            </tr>
         }
         <tr>
            <td><label for="PubYear" class="control-label">
                       Publication Year</label></td>
            <td><input for="PubYear" class="form-control" 
                       @bind="@book.PubYear" /></td>
         </tr>
         <tr>
            <td><label for="PurchDate" class="control-label">
                       Purchase Date</label></td>
            <td><input type="date" class="form-control" 
                       @bind="@book.PurchDate" /></td>
         </tr>
         <tr>
            <td><label for="Publisher" class="control-label">
                       Publisher</label></td>
            <td>
               <select for="Publisher" class="form-control" 
                       @bind="@book.PubId">
                  <option value=0 disabled selected hidden>
                  [Select Publisher]</option>
                  @foreach (var publisher in publishers)
                  {
                     <option value="@publisher.Id">
                             @publisher.Name</option>
                  }
               </select>
            </td>
            <td>
               &#8194;
               <a class="btn btn-primary"
               href='/addPublisher/@book.ISBN'>Add publisher</a>
            </td>
         </tr>
         <tr>
            <td></td>
            <td>
               <br />
               <button type="button" class="btn btn-primary"
                       @onclick="() => CreateBook()">
                  &#8195;Save&#8195;
               </button>&#8194;
               <button type="button" class="btn btn-warning"
                       @onclick="() => Cancel()">
                  &#8194;&#8201;Cancel&#8201;&#8194;
               </button>
            </td>
         </tr>
      </tbody>
   </table>
</form>
@code {
   [Parameter] public string PrevPage { get; set; }
   Book book = new Book();
   BookAuthorName bookAuthorName = new BookAuthorName();
   List<Publisher> publishers = new List<Publisher>();
   List<BookAuthorName>bookAuthorNames = new List<BookAuthorName>();
   List<Book> books = new List<Book>();
   bool hasAdded = false;
   protected override async Task OnInitializedAsync()
   {
      book.PurchDate = DateTime.Now;
      publishers = await publisherService.FetchAll();      
   }
   protected async Task CreateBook()
   {
      if (hasAdded)
      {
         navigationManager.NavigateTo(PrevPage);
      }
      else
      {
         await bookService.Create(book);
         bookAuthorNames = await bookAuthorService.FetchAll(0);
         hasAdded = !hasAdded;
      }
   }
   protected async Task CreateBook()
   {
      if (hasAdded)
      {
         navigationManager.NavigateTo(PrevPage);
      }
      else
      {
         await bookService.Create(book);
         bookAuthorNames = await bookAuthorService.FetchAll(0);
         hasAdded = !hasAdded;
      }
   }
   protected async Task CheckChanged(BookAuthorName bookAuthorName,
                                     object checkValue)
   {
      long isbn = 0;
      if (book.ISBN > isbn)
      {
         isbn = (long)book.ISBN;
         bookAuthorNames = await bookAuthorService.FetchAll(isbn);
         if ((bool)checkValue)
         {
            // insert
            bookAuthorName.ISBN = isbn;
            await bookAuthorService.Create(bookAuthorName);
         }
         else
         {
            //delete
            bookAuthorName.AuthorOrd = 0;
            await bookAuthorService.Delete
                  (isbn, bookAuthorName.AuthorId);
         }
         bookAuthorNames = await bookAuthorService.FetchAll(isbn);
      }
   }
   void Cancel()
   {
      navigationManager.NavigateTo(PrevPage);
   }
}

EditBook.razor

@page "/editBook/{PrevPage}/{Isbn:long}"
@inject IBookService bookService
@inject IPublisherService publisherService
@inject IBookAuthorService bookAuthorService
@inject Microsoft.AspNetCore.Components.NavigationManager navigationManager;
<h3>
   Edit Book
</h3>
<form>
   <table width="100%" cellpadding="0" cellspacing="0" border="0">
      <tbody>
         <tr>
            <td>
               <label for="ISBN" class="control-label">
                  ISBN
               </label>
            </td>
            <td>
               <input for="ISBN" class="form-control"
                      @bind="@book.ISBN" />
            </td>
         </tr>
         <tr>
            <td>
               <label for="Title" class="control-label">
                  Title
               </label>
            </td>
            <td>
               <input for="Title" class="form-control"
                      @bind="@book.Title" />
            </td>
         </tr>
         <tr>
            <td>
               <label for="Authors" class="control-label">
                  Authors
               </label>
            </td>
            <td>
               <label style="width: 95px;">
                  <u><i><b>Sequence</b></i></u>
               </label>
               <label><u><i><b>Full Name</b></i></u></label><br />
               @foreach (var bookAuthorName in bookAuthorNames)
               {
                  <input type="number"
                         @bind="@bookAuthorName.AuthorOrd"
                         @bind:event="oninput"
                         style="width: 75px;" min="0" max="5" />
                  @if (@bookAuthorName.ISBN > 0)
                  {
                     <input name="AreChecked" type="checkbox"
                            value="@bookAuthorName.AuthorId" checked
                            @onchange="eventArgs => { CheckChanged
                            (bookAuthorName, eventArgs.Value);}" />
                  }
                  else
                  {
                     <input name="AreChecked" type="checkbox"
                            value="@bookAuthorName.AuthorId"
                            @onchange="eventArgs => { CheckChanged
                            (bookAuthorName, eventArgs.Value);}" />
                  }
                  <label for="AuthorName" style="width: 150px;">
                     @bookAuthorName.AuthorName
                  </label><br />
               }
            </td>
            <td>
               &#8201;
               <a class="btn btn-primary"
                  href='/addAuthor/@book.ISBN'> 
                  &#8194;Add new author&#8194;
               </a><br />
            </td>
         </tr>
         <tr>
            <td>
               <label for="PubYear" class="control-label">
                  Publication Year
               </label>
            </td>
            <td>
               <input for="PubYear" class="form-control"
                      @bind="@book.PubYear" />
            </td>
         </tr>
         <tr>
            <td>
               <label for="PurchDate" class="control-label">
                  Purchase Date
               </label>
            </td>
            <td>
               <input type="date" class="form-control"
                      @bind="@book.PurchDate" />
            </td>
         </tr>
         <tr>
            <td>
               <label for="Publisher" class="control-label">
                  Publisher
               </label>
            </td>
            <td>
               <select for="Publisher" class="form-control"
                       @bind="@book.PubId">
                  @foreach (var publisher in publishers)
                  {
                     <option value="@publisher.Id">
                        @publisher.Name
                     </option>
                  }
               </select>
            </td>
            <td>
               &#8201;
               <a class="btn btn-primary"
                  href='/addPublisher/@book.ISBN'>Add new publisher
               </a>
            </td>
         </tr>
         <tr>
            <td></td>
            <td>
               <br />
               <button type="button" class="btn btn-primary"
                       @onclick="() => OpenDialog(book.Title)">
                  &#8195;Save&#8195;
               </button>&#8194;
               <button type="button" class="btn btn-warning"
                       @onclick="() => Cancel()">
                  &#8194;&#8201;Cancel&#8201;&#8194;
               </button>
            </td>
         </tr>
      </tbody>
   </table>
</form>
@if (DialogIsOpen)
{
   <Dialog
      Caption="Update a book"
      Message="@message"
      OnClose="@OnDialogClose"
      Type="Dialog.Category.SaveNot">
   </Dialog>
}
@code  {
   [Parameter] public string PrevPage { get; set; }
   [Parameter] public long Isbn { get; set; }
   Book book = new Book();
   BookAuthorName bookAuthorName = new BookAuthorName();
   List<Publisher> publishers = new List<Publisher>();
   List<BookAuthorName> bookAuthorNames=new List<BookAuthorName>();
   private string message;
   private bool DialogIsOpen = false;
   protected override async Task OnInitializedAsync()
   {
      book = await bookService.ReadByPk(Isbn);
      bookAuthorNames = await bookAuthorService.FetchAll(Isbn);
      publishers = await publisherService.FetchAll();
   }
   protected async Task CheckChanged(BookAuthorName bookAuthorName,
                                  object checkValue)
   {
      if ((bool)checkValue)
      {
         // insert
         bookAuthorName.ISBN = Isbn;
         await bookAuthorService.Create(bookAuthorName);
      }
      else
      {
         //delete
         bookAuthorName.AuthorOrd = null;
         await bookAuthorService.Delete
               (Isbn, bookAuthorName.AuthorId);
      }
      bookAuthorNames = await bookAuthorService.FetchAll(Isbn);
   }
   private void OpenDialog(string title)
   {
      DialogIsOpen = true;
      message = "Do you want to save the updates of the book \"" +
                title + "\"?";
   }
   private async Task OnDialogClose(bool isOk)
   {
      if (isOk)
      {
         await bookService.Update(book,Isbn);
         navigationManager.NavigateTo(PrevPage);
      }
      DialogIsOpen = false;
   }
   void Cancel()
   {
      navigationManager.NavigateTo(PrevPage);
   }
}

The modified code is in bold.

How It Works

Booklist per publisher

Figure 17 By default, all existing books are shown in the list
  • Click the Book/Publisher menu to open the ListBookPerBook.razor page.
  • By default, the option in the dropdown list is [All]; all existing books are shown in the list.

(1) Opening the page

Figure 18 Code to open ListBookPerBook.razor page
  • The page is opened via the NavMenu.razor component using /listBookPerPub route.

(2) Creating the dropdown list and set the default option to [All]

Figure 19 Code to create the dropdown list and set the default option to [All]
  • When the ListBookPerPub.razor page is opened, the app calls OnInitializedAsync() method and executes the SQL script in the FetchAll() method and stores the results in the publishers variable.
  • Through one-way data binding, the drop-down list in the HTML block automatically contains data according to the processing results in the code block.
  • Setting the default option to [All] is obtained via the statement on line 201 in the ListBookPerBook.razor file: private int idPubFilter = All;

(3) Displaying all the existing books

Figure 20 Code to display all existing books in the list
  • By default, the option in the dropdown list is [All]; all existing books are shown in the list.
  • The app calls InitializedAsync() method and executes the SQL script in the ListAll() method in the BookService.cs file.
  • Through one-way data binding, the HTML block automatically shows the details of the booklist (if any) according to the code block's processing results. Otherwise, it displays the message No Records to display.

(4) Displaying booklist of a publisher

Figure 21 Booklist of a publisher
  • Select a publisher from the dropdown list to display its booklist.
Figure 22 Code to display a books list of a publisher
  • Selecting a publisher from the dropdown list raises the onchange event that callsIdPubFilter property.
  • The IdPubFilter property sets the idPubFilter field and calls the InitalizedAsync method that executes the SQL script in the ReadByPk and the ListPerPub methods.
  • Through one-way data binding, the HTML block displays the city and country of the selected publisher. It automatically shows the booklist details (if any) according to the processing results in the code block. Otherwise, it displays the message No Records to display.

Booklist per author

Figure 23 Booklist of an author

It works similar to the booklist per publisher. In the master section, the publisher is replaced by the author. In the details section of the book, there are slight differences in the SQL script. In the booklist per author, SQL scripts use not only outer joins but also subquery.

Figure 24 ListPerPub and ListPerAuthor methods

Summary

In this article, we have learned how to create and implement two master-detail pages.

  1. Booklist per publisher as an implementation of the 1:N relationship. The master section shows the publisher, and the details section displays its booklist.
  2. Booklist per author as an implementation of the M:N relationship. The master section shows the author, and the details section displays its booklist.

We also have implemented dynamic navigations using parameterized routes.

Thanks for reading. Hopefully beneficial.

References

Blazor
Crud
Master Detail
Dynamic Navigation
Parameterized Route
Recommended from ReadMedium