Permalink
Switch branches/tags
Nothing to show
Find file Copy path
784 lines (651 sloc) 28 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 .NET Core from the project types on the left and select the ASP.NET Core Web Application template. Name the project "FrontEnd", name the solution "ConferencePlanner", and press OK.
  3. Select ASP.NET Core 2.1 from the drop-down list in the top-left corner
  4. Select the Web Application template and click OK
  5. Right-click on the FrontEnd project and 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 razor -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

Delete unwanted content

We'll clear out some content from the template that we don't need

  1. Open /Pages/Index.cshtml and delete all the HTML content (after line 6)
  2. Delete the following files:
    • /Pages/About.cshtml
    • /Pages/About.cshtml.cs
    • /Pages/Contact.cshtml
    • /Pages/Contact.cshtml.cs
  3. Open the /Pages/_Layout.cshtml file and delete the nav links to the pages you just deleted (lines 34 and 35)

Create and wire-up an API service client

We'll create a class to talk to our backend web API service

Create the API service client class

  1. Create a folder called Services in the root of the project
  2. In this folder, add a new interface called IApiClient with the following members:
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    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<List<SearchResult>> SearchAsync(string query);
           Task 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;
     using System.Collections.Generic;
     using System.Linq;
     using System.Net;
     using System.Net.Http;
     using System.Threading.Tasks;
     using ConferenceDTO;
     using FrontEnd.Infrastructure;
    
     namespace FrontEnd.Services
     {
         public class ApiClient : IApiClient
         {
             private readonly HttpClient _httpClient;
    
             public ApiClient(HttpClient httpClient)
             {
                 _httpClient = httpClient;
             }
    
             public async Task AddAttendeeAsync(Attendee attendee)
             {
                 var response = await _httpClient.PostJsonAsync($"/api/attendees", attendee);
    
                 response.EnsureSuccessStatusCode();
             }
    
             public async Task<AttendeeResponse> GetAttendeeAsync(string name)
             {
                 if (string.IsNullOrEmpty(name))
                 {
                     return null;
                 }
    
                 var response = await _httpClient.GetAsync($"/api/attendees/{name}");
    
                 if (response.StatusCode == HttpStatusCode.NotFound)
                 {
                     return null;
                 }
    
                 response.EnsureSuccessStatusCode();
    
                 return await response.Content.ReadAsJsonAsync<AttendeeResponse>();
             }
    
             public async Task<SessionResponse> GetSessionAsync(int id)
             {
                 var response = await _httpClient.GetAsync($"/api/sessions/{id}");
    
                 if (response.StatusCode == HttpStatusCode.NotFound)
                 {
                     return null;
                 }
    
                 response.EnsureSuccessStatusCode();
    
                 return await response.Content.ReadAsJsonAsync<SessionResponse>();
             }
    
             public async Task<List<SessionResponse>> GetSessionsAsync()
             {
                 var response = await _httpClient.GetAsync("/api/sessions");
    
                 response.EnsureSuccessStatusCode();
    
                 return await response.Content.ReadAsJsonAsync<List<SessionResponse>>();
             }
    
             public async Task DeleteSessionAsync(int id)
             {
                 var response = await _httpClient.DeleteAsync($"/api/sessions/{id}");
    
                 if (response.StatusCode == HttpStatusCode.NotFound)
                 {
                     return;
                 }
    
                 response.EnsureSuccessStatusCode();
             }
    
             public async Task<SpeakerResponse> GetSpeakerAsync(int id)
             {
                 var response = await _httpClient.GetAsync($"/api/speakers/{id}");
    
                 if (response.StatusCode == HttpStatusCode.NotFound)
                 {
                     return null;
                 }
    
                 response.EnsureSuccessStatusCode();
    
                 return await response.Content.ReadAsJsonAsync<SpeakerResponse>();
             }
    
             public async Task<List<SpeakerResponse>> GetSpeakersAsync()
             {
                 var response = await _httpClient.GetAsync("/api/speakers");
    
                 response.EnsureSuccessStatusCode();
    
                 return await response.Content.ReadAsJsonAsync<List<SpeakerResponse>>();
             }
    
             public async Task PutSessionAsync(Session session)
             {
                 var response = await _httpClient.PutJsonAsync($"/api/sessions/{session.ID}", session);
    
                 response.EnsureSuccessStatusCode();
             }
    
             //public async Task<List<SearchResult>> SearchAsync(string query)
             //{
             //    var term = new SearchTerm
             //    {
             //        Query = query
             //    };
             //
             //    var response = await _httpClient.PostJsonAsync($"/api/search", term);
             //
             //    response.EnsureSuccessStatusCode();
             //
             //    return await response.Content.ReadAsJsonAsync<List<SearchResult>>();
             //}
         }
     }
  4. The previous class relies on some extension methods to make working with JSON payloads a littler nicer with HttpClient so let's add those now.
  5. Create a directory in the root of the project called Infrastructure
  6. Add a new class called HttpClientExtensions with the following code:
     using System;
     using System.Collections.Generic;
     using System.IO;
     using System.Linq;
     using System.Net;
     using System.Net.Http;
     using System.Threading.Tasks;
     using Newtonsoft.Json;
    
     namespace FrontEnd.Infrastructure
     {
         public static class HttpClientExtensions
         {
             private static readonly JsonSerializer _jsonSerializer = new JsonSerializer();
    
             public static async Task<T> ReadAsJsonAsync<T>(this HttpContent httpContent)
             {
                 using (var stream = await httpContent.ReadAsStreamAsync())
                 {
                     var jsonReader = new JsonTextReader(new StreamReader(stream));
    
                     return _jsonSerializer.Deserialize<T>(jsonReader);
                 }
             }
    
             public static Task<HttpResponseMessage> PostJsonAsync<T>(this HttpClient client, string url, T value)
             {
                 return SendJsonAsync<T>(client, HttpMethod.Post, url, value);
             }
             public static Task<HttpResponseMessage> PutJsonAsync<T>(this HttpClient client, string url, T value)
             {
                 return SendJsonAsync<T>(client, HttpMethod.Put, url, value);
             }
    
             public static Task<HttpResponseMessage> SendJsonAsync<T>(this HttpClient client, HttpMethod method, string url, T value)
             {
                 var stream = new MemoryStream();
                 var jsonWriter = new JsonTextWriter(new StreamWriter(stream));
    
                 _jsonSerializer.Serialize(jsonWriter, value);
    
                 jsonWriter.Flush();
    
                 stream.Position = 0;
    
                 var request = new HttpRequestMessage(method, url)
                 {
                     Content = new StreamContent(stream)
                 };
    
                 request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/json");
    
                 return client.SendAsync(request);
             }
         }
     }

Configure the API client

  1. Add a reference to Microsoft.Extensions.Http using the NuGet Package Manager or dotnet CLI dotnet add package

  2. Open the Startup.cs file

  3. Locate the ConfigureServices method and add the following code to the bottom of it:

     services.AddHttpClient<IApiClient, ApiClient>(client =>
     {
         client.BaseAddress = new Uri(Configuration["serviceUrl"]);
     });
  4. This adds an instance of HttpClientFactory with its base URL pulled from the application configuration, which will point to our BackEnd API application

  5. Open the appsettings.json file and add the configuration key for serviceUrl pointing to the URL your specific BackEnd API application is configured to run in (check your launchSettings.json file for the specific port your BackEnd API application uses):

     "ServiceUrl": "https://localhost:56009/"

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

  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(IApiClient apiClient)
     {
         _apiClient = apiClient;
     }
  3. Add some properties to store sessions and other data we'll need when rendering the page:

     public IEnumerable<IGrouping<DateTimeOffset?, SessionResponse>> Sessions { get; set; }
    
     public IEnumerable<(int Offset, DayOfWeek? DayofWeek)> DayOffsets { get; set; }
    
     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);
         var endDate = sessions.Max(s => s.EndTime?.Date);
    
         var numberOfDays = ((endDate - startDate)?.Days) + 1;
    
         DayOffsets = Enumerable.Range(0, numberOfDays ?? 0)
             .Select(offset => (offset, (startDate?.AddDays(offset))?.DayOfWeek));
    
         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

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

  2. Add some 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. Run the FrontEnd application at this stage and we should see the sessions listed on the home page

Add buttons to allow showing sessions for different days

  1. In /Pages/Index.cshtml, add some markup to allow the user to show sessions for the different days of the conference, below the <h1> we added previously:

     <ul class="nav nav-pills">
         @foreach (var day in Model.DayOffsets)
         {
             <li role="presentation" class="@(Model.CurrentDayOffset == day.Offset ? "active" : null)">
                 <a asp-route-day="@day.Offset">@day.DayofWeek?.ToString()</a>
             </li>
         }
     </ul>
  2. 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 some CSS and components from Bootstrap:

     @foreach (var timeSlot in Model.Sessions)
     {
         <h4>@timeSlot.Key?.ToString("HH:mm")</h4>
         <div class="row">
             @foreach (var session in timeSlot)
             {
                 <div class="col-md-3">
                     <div class="panel panel-default session">
                         <div class="panel-body">
                             <p>@session.Track?.Name</p>
                             <h3 class="panel-title"><a asp-page="Session" asp-route-id="@session.ID">@session.Title</a></h3>
                             <p>
                             @foreach (var speaker in session.Speakers)
                             {
                                 <em><a asp-page="Speaker" asp-route-id="@speaker.ID">@speaker.Name</a></em>
                             }
                             </p>
                         </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.

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 template. Call the page Session.cshtml and save it in the /Pages directory.
  2. Open Session.cshtml.cs and change the page model class to SessionModel.
  3. Accept the IApiClient in the constructor and add supporting members to the Page model SessionModel:
     public class SessionModel : PageModel
     {
         private readonly IApiClient _apiClient;
    
         public SessionModel(IApiClient apiClient)
         {
             _apiClient = apiClient;
         }
    
         public SessionResponse Session { get; set; }
    
         public int? DayOffset { get; set; }
     }
  4. Add a page handler method to retrieve the Session details and set them on the model:
     public async Task<IActionResult> OnGetAsync(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;
    
         if (!string.IsNullOrEmpty(Session.Abstract))
         {
             Session.Abstract = "<p>" + String.Join("</p><p>", Session.Abstract.Split("\r\n", StringSplitOptions.RemoveEmptyEntries)) + "</p>";
         }
    
         return Page();
     }
  5. Open the Session.cshtml file and add markup to display the details and navigation UI:
     @page "{id}"
     @model SessionModel
    
     <ol class="breadcrumb">
         <li><a asp-page="/Index">Agenda</a></li>
         <li><a asp-page="/Index" asp-route-day="@Model.DayOffset">Day @(Model.DayOffset + 1)</a></li>
         <li class="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>
     }
    
     <p>@Html.Raw(Model.Session.Abstract)</p>

HTML Encoding the Session Abstract

Currently, the Session Abstract is displayed using @Html.Raw(). This makes it vulnerable to Cross-Site Scripting (XSS) and Injection Attacks, since an abstract could contain JavaScript. We'll update Session.cshtml.cs to HTML encode the Abstract to protect against these attacks.

  1. Add an HtmlEncoder field to the SessionModel using the following code:
    private readonly HtmlEncoder _htmlEncoder;
  2. Update the SessionModel constructor to inject an HtmlEncoder:
     public SessionModel(IApiClient apiClient, HtmlEncoder htmlEncoder)
     {
         _apiClient = apiClient;
         _htmlEncoder = htmlEncoder;
     }
  3. Update the section of the OnGet() method that handles Session.Abstract to encode the output.
     if (!string.IsNullOrEmpty(Session.Abstract))
     {
         var encodedCrLf = _htmlEncoder.Encode("\r\n");
         var encodedAbstract = _htmlEncoder.Encode(Session.Abstract);
         Session.Abstract = "<p>" + String.Join("</p><p>", encodedAbstract.Split(encodedCrLf, StringSplitOptions.RemoveEmptyEntries)) + "</p>";
     }

Add a page to show speaker details

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

  1. Add a new Razor Page using the Razor Page template. Call the page Speaker.cshtml and save it in the /Pages directory.
  2. Open Speaker.cshtml.cs and change the page model class to SpeakerModel.
  3. Accept the IApiClient in the constructor and add supporting members to the Page model SpeakerModel:
     public class SpeakerModel : PageModel
     {
         private readonly IApiClient _apiClient;
    
         public SpeakerModel(IApiClient apiClient)
         {
             _apiClient = apiClient;
         }
    
         public SpeakerResponse Speaker { get; set; }
     }
  4. 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();
     }
  5. Open the Speaker.cshtml file and add markup to display the details and navigation UI:
     @page "{id}"
     @model SpeakerModel
    
     <ol class="breadcrumb">
         <li><a asp-page="/Speakers">Speakers</a></li>
         <li class="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.

Add DTOs for search

  1. Add a reference to the Newtonsoft.Json NuGet package in the DTO project:
    dotnet add package Newtonsoft.Json
    
  2. Add a new DTO class SearchTerm in the DTO project:
    using System;
    using System.Collections.Generic;
    using System.Text;
    
    namespace ConferenceDTO
    {
        public class SearchTerm
        {
            public string Query { get; set; }
        }
    }
  3. Add a new DTO class SearchResult in the DTO project:
    using System;
    using System.Collections.Generic;
    using System.Text;
    using Newtonsoft.Json.Linq;
    
    namespace ConferenceDTO
    {
        public class SearchResult
        {
            public SearchResultType Type { get; set; }
    
            public JObject Value { get; set; }
        }
    
        public enum SearchResultType
        {
            Attendee,
            Conference,
            Session,
            Track,
            Speaker
        }
    }

Add a search controller

  1. Add a SearchController with an action method that accepts a SearchTerm and searchs for sessions and speakers with matching titles or names, and concatenates the results as SearchResults:
    using System;
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    using BackEnd.Data;
    using ConferenceDTO;
    using Microsoft.AspNetCore.Mvc;
    using Microsoft.EntityFrameworkCore;
    using Newtonsoft.Json.Linq;
    
    namespace BackEnd.Controllers
    {
        [Route("api/[controller]")]
        public class SearchController : Controller
        {
            private readonly ApplicationDbContext _db;
    
            public SearchController(ApplicationDbContext db)
            {
                _db = db;
            }
    
            [HttpPost]
            public async Task<IActionResult> Search([FromBody] SearchTerm term)
            {
                var query = term.Query;
    
                var sessionResultsTask = _db.Sessions.AsNoTracking()
                                                       .Include(s => s.Track)
                                                       .Include(s => s.SessionSpeakers)
                                                         .ThenInclude(ss => ss.Speaker)
                                                       .Where(s =>
                                                           s.Title.Contains(query) ||
                                                           s.Track.Name.Contains(query)
                                                       )
                                                       .ToListAsync();
    
                var speakerResultsTask = _db.Speakers.AsNoTracking()
                                                       .Include(s => s.SessionSpeakers)
                                                         .ThenInclude(ss => ss.Session)
                                                       .Where(s =>
                                                           s.Name.Contains(query) ||
                                                           s.Bio.Contains(query) ||
                                                           s.WebSite.Contains(query)
                                                       )
                                                       .ToListAsync();
    
                var sessionResults = await sessionResultsTask;
                var speakerResults = await speakerResultsTask;
    
                var results = sessionResults.Select(s => new SearchResult
                {
                    Type = SearchResultType.Session,
                    Value = JObject.FromObject(s.MapSessionResponse())
                })
                .Concat(speakerResults.Select(s => new SearchResult
                {
                    Type = SearchResultType.Speaker,
                    Value = JObject.FromObject(s.MapSpeakerResponse())
                }));
    
                return Ok(results);
            }
        }
    }

Add a search page to the Front End

  1. Uncomment the search-related lines in IApiClient and ApiClient in the Services folder.
  2. Add a new Razor Page using the Razor Page template. Call the page Search.cshtml and save it in the /Pages directory.
  3. Open Search.cshtml.cs and change the page model class to SearchModel.
  4. Accept the IApiClient in the constructor and add supporting members to the Page model SearchModel:
     public class SearchModel : PageModel
     {
         private readonly IApiClient _apiClient;
    
         public SearchModel(IApiClient apiClient)
         {
             _apiClient = apiClient;
         }
    
         public string Term { get; set; }
    
         public List<object> SearchResults { get; set; }
     }
  5. 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;
         var results = await _apiClient.SearchAsync(term);
         SearchResults = results.Select(sr =>
                         {
                             switch (sr.Type)
                             {
                                 case SearchResultType.Session:
                                     return (object)sr.Value.ToObject<SessionResponse>();
                                 case SearchResultType.Speaker:
                                     return (object)sr.Value.ToObject<SpeakerResponse>();
                                 default:
                                   return (object)sr;
                             }
                         })
                         .ToList();
     }
  6. 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="row">
         <h1>Search</h1>
         <form method="get">
             <div class="input-group">
                 <input asp-for="Term" class="form-control" />
                 <span class="input-group-btn">
                     <button class="btn btn-default" type="submit">Go!</button>
                 </span>
             </div>
         </form>
     </div>
     <br />
     <div class="row">
         @foreach (var result in Model.SearchResults)
         {
             <div>
                 @switch (result)
                 {
                     case SpeakerResponse speaker:
                         <div class="panel panel-default">
                             <div class="panel-heading">
                                 <h3 class="panel-title">Speaker: <a asp-page="Speaker" asp-route-id="@speaker.ID">@speaker.Name</a></h3>
                             </div>
                             <div class="panel-body">
                                 <p>
                                     @foreach (var session in speaker.Sessions)
                                     {
                                         <a asp-page="/Session" asp-route-id="@session.ID"><em>@session.Title</em></a>
                                     }
                                 </p>
                                 <p>
                                     @speaker.Bio
                                 </p>
                             </div>
                         </div>
                         break;
    
                     case SessionResponse session:
                         <div class="panel panel-default">
                             <div class="panel-heading">
                                 <h3 class="panel-title">Session: <a asp-page="Session" asp-route-id="@session.ID">@session.Title</a></h3>
                                 @foreach (var speaker in session.Speakers)
                                 {
                                     <a asp-page="/Speaker" asp-route-id="@speaker.ID"><em>@speaker.Name</em></a>
                                 }
                             </div>
                             <div class="panel-body">
                                 <p>
                                     @session.Abstract
                                 </p>
                             </div>
                         </div>
                         break;
                 }
             </div>
         }
     </div>

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