Skip to content

Latest commit

 

History

History
781 lines (633 loc) · 29.1 KB

3. Add front-end, render agenda, set up front-end models.md

File metadata and controls

781 lines (633 loc) · 29.1 KB

Building the Front End

In this session, we'll add the front end web site, with a public (anonymous) home page showing the conference agenda.

Add a FrontEnd project

We'll start by creating the new front end project for the web site.

Adding the FrontEnd Project using Visual Studio

  1. If using Visual Studio, right-click on the Solution and select Add > New Project....
  2. Select the ASP.NET Core Web App template. Name the project FrontEnd and press OK.
  3. Select ASP.NET Core 6.0 (LTS) from the drop-down list in the top-left corner.
  4. Leave all the other default values and clidk the Create button.
  5. Right-click on the FrontEnd project and select Add > Project Reference, then add a reference to the ConferenceDTO project.

Adding the FrontEnd Project via the Command Line

  1. Open a command prompt and navigate to the root ConferencePlanner directory.

  2. Run the following command:

    dotnet new webapp -o FrontEnd
  3. Next we'll need to add a reference to the ConferenceDTO project from the new FrontEnd project. From the command line, navigate to the FrontEnd project directory and execute the following command:

    dotnet add reference ../ConferenceDTO/ConferenceDTO.csproj

Create and wire-up an API service client

Our FrontEnd project has a reference to ConferenceDTO, but not BackEnd. The FrontEnd will communicate with the BackEnd using the HTTP endpoints. We'll create a common class to talk to our backend web API service using .NET's HttpClient.

Create the API service client class

  1. Create a folder called Services in the root of the FrontEnd project.

  2. In this folder, add a new interface called IApiClient with the following members:

     using ConferenceDTO;
    
     namespace FrontEnd.Services;
    
     public interface IApiClient
     {
         Task<List<SessionResponse>> GetSessionsAsync();
         Task<SessionResponse?> GetSessionAsync(int id);
         Task<List<SpeakerResponse>> GetSpeakersAsync();
         Task<SpeakerResponse?> GetSpeakerAsync(int id);
         Task PutSessionAsync(Session session);
         Task<bool> AddAttendeeAsync(Attendee attendee);
         Task<AttendeeResponse?> GetAttendeeAsync(string name);
         Task DeleteSessionAsync(int id);
     }
  3. Staying in this folder, add a new class called ApiClient that implements the IApiClient interface by using HttpClient to call out to our BackEnd API application and JSON serialize/deserialize the payloads:

     using System.Net;
     using ConferenceDTO;
    
     namespace FrontEnd.Services;
    
     public class ApiClient : IApiClient
     {
         private readonly HttpClient _httpClient;
    
         public ApiClient(HttpClient httpClient)
         {
             _httpClient = httpClient;
         }
    
         public async Task<bool> AddAttendeeAsync(Attendee attendee)
         {
             var response = await _httpClient.PostAsJsonAsync($"/api/Attendee", attendee);
             if (response.StatusCode == HttpStatusCode.Conflict)
             {
                 return false;
             }
             response.EnsureSuccessStatusCode();
             return true;
         }
    
         public async Task<AttendeeResponse?> GetAttendeeAsync(string name)
         {
             if (string.IsNullOrEmpty(name))
             {
                 return null;
             }
             var response = await _httpClient.GetAsync($"/api/Attendee/{name}");
             if (response.StatusCode == HttpStatusCode.NotFound)
             {
                 return null;
             }
             response.EnsureSuccessStatusCode();
             return await response.Content.ReadFromJsonAsync<AttendeeResponse>();
         }
    
         public async Task<SessionResponse?> GetSessionAsync(int id)
         {
             var response = await _httpClient.GetAsync($"/api/Session/{id}");
             if (response.StatusCode == HttpStatusCode.NotFound)
             {
                 return null;
             }
             response.EnsureSuccessStatusCode();
    
             return await response.Content.ReadFromJsonAsync<SessionResponse>();
         }
    
         public async Task<List<SessionResponse>> GetSessionsAsync()
         {
             var response = await _httpClient.GetAsync("/api/Session");
             response.EnsureSuccessStatusCode();
             return await response.Content.ReadFromJsonAsync<List<SessionResponse>>() ?? new();
         }
    
         public async Task DeleteSessionAsync(int id)
         {
             var response = await _httpClient.DeleteAsync($"/api/Session/{id}");
             if (response.StatusCode == HttpStatusCode.NotFound)
             {
                 return;
             }
             response.EnsureSuccessStatusCode();
         }
    
         public async Task<SpeakerResponse?> GetSpeakerAsync(int id)
         {
             var response = await _httpClient.GetAsync($"/api/Speaker/{id}");
             if (response.StatusCode == HttpStatusCode.NotFound)
             {
                 return null;
             }
             response.EnsureSuccessStatusCode();
             return await response.Content.ReadFromJsonAsync<SpeakerResponse>();
         }
    
         public async Task<List<SpeakerResponse>> GetSpeakersAsync()
         {
             var response = await _httpClient.GetAsync("/api/Speaker");
             response.EnsureSuccessStatusCode();
             return await response.Content.ReadFromJsonAsync<List<SpeakerResponse>>() ?? new();
         }
    
         public async Task PutSessionAsync(Session session)
         {
             var response = await _httpClient.PutAsJsonAsync($"/api/Session/{session.Id}", session);
             response.EnsureSuccessStatusCode();
         }
     }

Configure the API client

  1. Open the Program.cs file

  2. Locate the line which reads var app = builder.Build(); and add the following code above it:

     builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
     {
         client.BaseAddress = new Uri(builder.Configuration["serviceUrl"]);
     });

This adds an instance of HttpClientFactory with its base URL pulled from the application configuration, which will point to our BackEnd API application.

  1. Add a using statement for FrontEnd.Services to Program.cs.

  2. Find the URL for your BackEnd API in the BackEnd/Properties/launchSettings.json file. It will be on a line that lists both an http and https URL - you want the https one. By default, you won't be running on IIS Express, so you don't need that URL.

  3. Open the appsettings.json file in your FrontEnd project and add the configuration key for serviceUrl pointing to the URL your specific BackEnd API application is configured to run in. The result should look like this:

     {
     "ServiceUrl": "https://localhost:7112",
     "Logging": {
         "LogLevel": {
         "Default": "Information",
         "Microsoft.AspNetCore": "Warning"
         }
     },
     "AllowedHosts": "*"
     }

ASP.NET Core has a rich configuration system. Often, simple configuration variables are stored in appsettings.json in development and overwritten by environment varibles or other configuration providers in production.

Warning You should never store secrets like passwords, tokens, or database connection strings in appsettings.json. Secrets should be stored securely and outside of your application source code directory, for instance in the Secret Manager.

List the sessions on the home page

Now that we have an API client we can use to talk to our BackEnd API application, we'll update the home page to show a basic list of all sessions for the conference to ensure the FrontEnd can talk to the BackEnd correctly.

Load the data into the PageModel

The home page of the site will display our conference sessions. In ASP.NET Core Razor Pages, the default page is Index.cshtml. Razor Pages uses a combination of a view template file (e.g. Index.cshtml) and a paired PageModel class (e.g. Index.cshtml.cs). The PageModel class allows separation of the logic of a page from its presentation. It defines page handlers for requests sent to the page and the data used to render the page.

The OnGet method executes when the page is accessed via HTTP Get (as opposed to other HTTP verbs, like HTTP Post). This method completes before the view (Index.cshtml) is rendered, so its job is to prepare all the dynamic content the page will display.

To start with, our OnGet method's job will be to load all the sessions using the ApiClient. For our display purposes, we want to allow browsing through the conference sessions grouped by day and ordered by start time. We'll do that using standard LINQ methods.

  1. Open the /Pages/Index.cshtml.cs file

  2. Edit the constructor to accept the IApiClient interface and store it in a local field:

     protected readonly IApiClient _apiClient;
    
     public IndexModel(ILogger<IndexModel> logger, IApiClient apiClient)
     {
         _logger = logger;
         _apiClient = apiClient;
     }
  3. Add some properties to the IndexModel class to store sessions and other data we'll need when rendering the page (adding a using ConferenceDTO; statement to resolve the compiler error):

    public IEnumerable<IGrouping<DateTimeOffset?, SessionResponse>> Sessions { get; set; } = null!;
    
    public IEnumerable<(int Offset, DayOfWeek? DayofWeek)> DayOffsets { get; set; } = null!;
    
    public int CurrentDayOffset { get; set; }
  4. Add a page handler method to handle GET requests to the page, that loads the session data and calculates the data required to build the day navigation UI:

    public async Task OnGet(int day = 0)
    {
        CurrentDayOffset = day;
    
        var sessions = await _apiClient.GetSessionsAsync();
    
        var startDate = sessions.Min(s => s.StartTime?.Date);
    
        DayOffsets = sessions.Select(s => s.StartTime?.Date)
                             .Distinct()
                             .OrderBy(d => d)
                             .Select(day => ((int)Math.Floor((day!.Value - startDate)?.TotalDays ?? 0),
                                             day?.DayOfWeek))
                             .ToList();
    
        var filterDate = startDate?.AddDays(day);
    
        Sessions = sessions.Where(s => s.StartTime?.Date == filterDate)
                           .OrderBy(s => s.TrackId)
                           .GroupBy(s => s.StartTime)
                           .OrderBy(g => g.Key);
    }

Render the sessions list on the home page

Now that we have loaded the sessions into the PageModel class properties, we can render them in the page.

  1. Open the /Pages/Index.cshtml Razor Page file

  2. Replace the <div> containing the welcome message with the following Razor markup to show the sessions as a simple list, grouped by time-slot:

    <div class="agenda">
        <h1>My Conference @System.DateTime.Now.Year</h1>
    
        @foreach (var timeSlot in Model.Sessions)
        {
            <h4>@timeSlot.Key?.ToString("HH:mm")</h4>
            <ul>
                @foreach (var session in timeSlot)
                {
                    <li>@session.Title</li>
                }
            </ul>
        }
    </div>
  3. Right-click the solution, select Properties and set both BackEnd and FrontEnd as startup projects.

  4. Run the FrontEnd application at this stage and we should see the sessions listed on the home page

Creating multiple startup projects in VS Code can be done by updating the launch.json file with the compounds part. Here is an example launch.json file which will run both projects and display the Swagger page for the BackEnd project.

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "BackEnd",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            "program": "${workspaceFolder}/BackEnd/bin/Debug/net6.0/BackEnd.dll",
            "args": [],
            "cwd": "${workspaceFolder}/BackEnd",
            "stopAtEntry": false,
            "serverReadyAction": {
                "action": "openExternally",
                "pattern": "\\bNow listening on:\\s+(https?://\\S+)",
                "uriFormat": "%s/swagger"
            },
            "env": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            },
            "sourceFileMap": {
                "/Views": "${workspaceFolder}/Views"
            }
        },
        {
            "name": "FrontEnd",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            "program": "${workspaceFolder}/FrontEnd/bin/Debug/net6.0/FrontEnd.dll",
            "args": [],
            "cwd": "${workspaceFolder}/FrontEnd",
            "stopAtEntry": false,
            "serverReadyAction": {
                "action": "openExternally",
                "pattern": "\\bNow listening on:\\s+(https?://\\S+)"
            },
            "env": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            },
            "sourceFileMap": {
                "/Views": "${workspaceFolder}/Views"
            }
        },
        {
            "name": ".NET Core Attach",
            "type": "coreclr",
            "request": "attach"
        }
    ],
    "compounds": [
        {
            "name": "FrontEnd/BackEnd",
            "configurations": [
                "FrontEnd",
                "BackEnd"
            ]
        }
    ]
}

Add buttons to allow showing sessions for different days

The OnGet method takes a parameter to select a day of the conference to display. Let's add buttons to allow the user to show sessions for the different days of the conference.

  1. In /Pages/Index.cshtml, add the following markup , below the <h1> we added previously:

    <ul class="nav nav-pills mb-3">
        @foreach (var day in Model.DayOffsets)
        {
            <li role="presentation" class="nav-item">
                <a class="nav-link @(Model.CurrentDayOffset == day.Offset ? "active" : null)" asp-route-day="@day.Offset">@day.DayofWeek?.ToString()</a>
            </li>
        }
    </ul>

We're using Bootstrap attributes like nav and mb-3 to style the content.

What's interesting here is the navigation element. The asp-route-day attribute is a tag helper which passes the route value in the URL to the page, where it is processed by the OnGet method's filter logic.

As each button is rendered, the logic determines if it is the active day and sets the active attribute, causing Bootstrap to style it as such.

  1. Run the application again and try clicking the buttons to show sessions for the different days.

Update the sessions list UI

  1. Make the list of sessions better looking by updating the markup to use Bootstrap cards:

    <h4>@timeSlot.Key?.ToString("HH:mm")</h4>
    <div class="row">
    @foreach (var session in timeSlot)
     {
         <div class="col-md-3 mb-4">
             <div class="card shadow session h-100">
                 <div class="card-header">@session.Track?.Name</div>
                 <div class="card-body">
                     <h5 class="card-title"><a asp-page="Session" asp-route-id="@session.Id">@session.Title</a></h5>
                 </div>
                 <div class="card-footer">
                     <ul class="list-inline mb-0">
                         @foreach (var speaker in session.Speakers)
                         {
                             <li class="list-inline-item">
                                 <a asp-page="Speaker" asp-route-id="@speaker.Id">@speaker.Name</a>
                             </li>
                         }
                     </ul>
                 </div>
             </div>
         </div>
     }
    </div>
  2. Run the page again and see the updated sessions list UI. Click the buttons again to show sessions for the different days.

Note The sessions and speakers appear as links, but those links aren't hooked up yet. We'll implement those next.

Add a session details page

Now that we have a home page showing all the sessions, we'll create a page to show all the details of a specific session.

Add a Session Razor Page

  1. Add a new Razor Page using the Razor Page - Empty template. Call the page 'Session.cshtml' and save it in the /Pages directory.

  2. Accept the IApiClient in the constructor and add supporting members to the Page model SessionModel. The class should look like this:

     using ConferenceDTO;
     using FrontEnd.Services;
     using Microsoft.AspNetCore.Mvc;
     using Microsoft.AspNetCore.Mvc.RazorPages;
    
     namespace FrontEnd.Pages;
     public class SessionModel : PageModel
     {
         private readonly IApiClient _apiClient;
         public SessionResponse? Session { get; set; }
         public int? DayOffset { get; set; }
    
         public SessionModel(IApiClient apiClient)
         {
             _apiClient = apiClient;
         }
     }
  3. Add a page handler method to retrieve the Session details and set them on the model:

    public async Task<IActionResult> OnGet(int id)
    {
        Session = await _apiClient.GetSessionAsync(id);
    
        if (Session == null)
        {
            return RedirectToPage("/Index");
        }
    
        var allSessions = await _apiClient.GetSessionsAsync();
        var startDate = allSessions.Min(s => s.StartTime?.Date);
        DayOffset = Session.StartTime?.Subtract(startDate ?? DateTimeOffset.MinValue).Days;
    
         return Page();
     }

This OnGetAsync method attempts to load the session by Id. If it fails, we redirect back to the main page with the session list. If it succeeds, we perform an additional request and do some quick date math to determine which day of the conference the session is on.

  1. Open the Session.cshtml file and add markup to display the details and navigation UI:

     @page "{id}"
     @model SessionModel
    
     <ol class="breadcrumb">
         <li class="breadcrumb-item"><a asp-page="/Index">Agenda</a></li>
         <li class="breadcrumb-item"><a asp-page="/Index" asp-route-day="@Model.DayOffset">Day @(Model.DayOffset + 1)</a></li>
         <li class="breadcrumb-item active">@Model.Session!.Title</li>
     </ol>
    
     <h1>@Model.Session.Title</h1>
     <span class="label label-default">@Model.Session.Track?.Name</span>
    
     @foreach (var speaker in Model.Session.Speakers)
     {
         <em><a asp-page="Speaker" asp-route-id="@speaker.Id">@speaker.Name</a></em>
     }
    
     @foreach (var para in Model.Session!.Abstract!.Split("\r\n", StringSplitOptions.RemoveEmptyEntries))
     {
         <p>@para</p>
     }

Add a page to show speaker details

We'll next add a page to show details for a given speaker.

  1. Add a new Razor Page using the Razor Page - Empty template. Call the page Speaker.cshtml and save it in the /Pages directory.

  2. Accept the IApiClient in the constructor and add supporting members to the Page model SpeakerModel:

    public class SpeakerModel : PageModel
    {
        private readonly IApiClient _apiClient;
        public SpeakerResponse Speaker { get; set; }
    
        public SpeakerModel(IApiClient apiClient)
        {
            _apiClient = apiClient;
        }
    }
  3. Add a page handler method to retrieve the Speaker details and set them on the model:

    public async Task<IActionResult> OnGet(int id)
    {
        Speaker = await _apiClient.GetSpeakerAsync(id);
    
        if (Speaker == null)
        {
            return NotFound();
        }
    
        return Page();
    }
  4. Open the Speaker.cshtml file and add markup to display the details and navigation UI:

     @page "{id}"
     @model SpeakerModel
    
     <ol class="breadcrumb">
         <li class="breadcrumb-item"><a asp-page="/Speakers">Speakers</a></li>
         <li class="breadcrumb-item active">@Model.Speaker!.Name</li>
     </ol>
    
     <h2>@Model.Speaker.Name</h2>
    
     <p>@Model.Speaker.Bio</p>
    
     <h3>Sessions</h3>
     <div class="row">
         <div class="col-md-5">
             <ul class="list-group">
                 @foreach (var session in Model.Speaker.Sessions)
                 {
                     <li class="list-group-item">
                         <a asp-page="Session" asp-route-id="@session.Id">@session.Title</a>
                     </li>
                 }
             </ul>
         </div>
     </div>

Add search functionality

We'll add a page to allow users to search the conference agenda, finding sessions and speakers that match the supplied search term. This will require work on every part of our application - the BackEnd will process the search, the FrontEnd will display it, and both will use new classes in the ConferenceDTO library.

Add DTO for search results

  1. Add a new DTO class SearchResult in the DTO project:

     namespace ConferenceDTO;
    
     public record SearchResult
     {
         public SearchResultType Type { get; set; }
         public SessionResponse? Session { get; set; }
         public SpeakerResponse? Speaker { get; set; }
     }
    
     public enum SearchResultType
     {
         Session,
         Speaker
     }

Add a search endpoint

  1. Add a SearchEndpoints class to the Endpoints directory in the BackEnd project. It just has one endpoint that accepts a search term and searchs for sessions and speakers with matching titles or names, and concatenates the results as a List<SearchResult>:

     using BackEnd.Data;
     using ConferenceDTO;
     using Microsoft.EntityFrameworkCore;
    
     namespace BackEnd.Endpoints
     {
         public static class SearchEndpoints
         {
             public static void MapSearchEndpoints(this IEndpointRouteBuilder routes)
             {
                 routes.MapGet("/api/Search/{term}", async (string term, ApplicationDbContext db) =>
                 {
                     var sessionResults = await db.Sessions.Include(s => s.Track)
                                             .Include(s => s.SessionSpeakers)
                                             .ThenInclude(ss => ss.Speaker)
                                             .Where(s =>
                                                 s.Title!.Contains(term) ||
                                                 s.Track!.Name!.Contains(term)
                                             )
                                             .ToListAsync();
    
                     var speakerResults = await db.Speakers.Include(s => s.SessionSpeakers)
                                             .ThenInclude(ss => ss.Session)
                                             .Where(s =>
                                                 s.Name!.Contains(term) ||
                                                 s.Bio!.Contains(term) ||
                                                 s.WebSite!.Contains(term)
                                             )
                                             .ToListAsync();
    
                     var results = sessionResults.Select(s => new SearchResult
                     {
                         Type = SearchResultType.Session,
                         Session = s.MapSessionResponse()
                     })
                     .Concat(speakerResults.Select(s => new SearchResult
                     {
                         Type = SearchResultType.Speaker,
                         Speaker = s.MapSpeakerResponse()
                     }));
    
                     return results
                         is IEnumerable<SearchResult> model
                             ? Results.Ok(model)
                             : Results.NotFound();
                 })
                 .WithTags("Search")
                 .WithName("GetSearchResults")
                 .Produces<IEnumerable<SearchResult>>(StatusCodes.Status200OK)
                 .Produces(StatusCodes.Status404NotFound);
             }
         }
     }

Add search methods to the IApiClient

  1. Add the SearchAsync method to IApiClient:

    Task<List<SearchResult>> SearchAsync(string query);
  2. Add the implementation to ApiClient:

     public async Task<List<SearchResult>> SearchAsync(string term)
     {
         var response = await _httpClient.GetAsync($"/api/search/{term}");
         response.EnsureSuccessStatusCode();
         return await response.Content.ReadFromJsonAsync<List<SearchResult>>() ?? new();
     }

Add a search page to the Front End

  1. Add a new Razor Page using the Razor Page - Empty template. Call the page Search.cshtml and save it in the /Pages directory.

  2. Accept the IApiClient in the constructor and add supporting members to the Page model SearchModel:

     public class SearchModel : PageModel
     {
         private readonly IApiClient _apiClient;
         public string Term { get; set; } = String.Empty;
         public List<SearchResult> SearchResults { get; set; } = new();
    
         public SearchModel(IApiClient apiClient)
         {
             _apiClient = apiClient;
         }
     }
  3. Add a page handler method to retrieve the search results and set them on the model, deserializing the individual search items to the relevant model type:

     public async Task OnGetAsync(string term)
     {
         Term = term;
         if (!string.IsNullOrWhiteSpace(term))
         {
             SearchResults = await _apiClient.SearchAsync(term);
         }
     }
  4. Open the Search.cshtml file and add markup to allow users to enter a search term and display the results, casting each result to the relevant display model type:

     @page
     @using ConferenceDTO
     @model SearchModel
    
     <div class="search">
         <h1>Search</h1>
         <form method="get">
             <div class="input-group mb-3">
                 <input asp-for="Term" placeholder="Search for sessions or speakers..." class="form-control" />
                 <div class="input-group-append">
                     <button class="btn btn-outline-secondary" type="submit">Go!</button>
                 </div>
             </div>
             @if (Model.SearchResults?.Count > 0)
             {
                 <p>
                     @Model.SearchResults.Count result(s)
                 </p>
             }
         </form>
     </div>
    
     <div class="row">
         @foreach (var result in Model.SearchResults!)
         {
             <div class="col-md-12">
                 @switch (result.Type)
                 {
                     case SearchResultType.Speaker:
                         <div class="card shadow mb-3">
                             <div class="card-header">
                                 <h3 class="card-title">
                                     Speaker:
                                     <a asp-page="Speaker" asp-route-id="@result.Speaker.Id">
                                         @result.Speaker!.Name
                                     </a>
                                 </h3>
                             </div>
                             <div class="card-body">
                                 <p>
                                     @foreach (var session in result.Speaker.Sessions)
                                     {
                                         <a asp-page="/Session" asp-route-id="@session.Id">
                                             <em>@session.Title</em>
                                         </a>
                                     }
                                 </p>
                                 <p>
                                     @result.Speaker.Bio
                                 </p>
                             </div>
                         </div>
                         break;
    
                     case SearchResultType.Session:
                         <div class="card shadow mb-3">
                             <div class="card-header">
                                 <h3 class="card-title">
                                     Session:
                                     <a asp-page="Session" asp-route-id="@result.Session.Id">@result.Session!.Title</a>
                                 </h3>
                                 @foreach (var speaker in result.Session.Speakers)
                                 {
                                     <a asp-page="/Speaker" asp-route-id="@speaker.Id">
                                         <em>@speaker.Name</em>
                                     </a>
                                 }
                             </div>
                             <div class="card-body">
                                 <p>
                                     @result.Session.Abstract
                                 </p>
                             </div>
                         </div>
                         break;
                 }
             </div>
         }
     </div>
  5. Add the search link to the navigation pane in Pages/Shared/_Layout.cshtml:

    <li class="nav-item">
        <a class="nav-link text-dark" asp-page="/Search">Search</a>
    </li>
  6. Click on the Search link to test the new search feature.

Next: Session #4 - Authentication | Previous: Session #2 - Back-end