Your customers can order pizzas, but so far have no way to see the status of their orders. In this session you'll implement a "My orders" page that lists multiple orders, plus an "Order details" view showing the contents and status of an individual order.
Open Shared/MainLayout.cshtml
. As an experiment, let's try adding a new link element without using NavLink
. Add a plain HTML <a>
tag pointing to myorders
:
<div class="top-bar">
(leave existing content in place)
<a href="myorders" class="nav-tab">
<img src="img/bike.svg" />
<div>My Orders</div>
</a>
</div>
Notice how the URL we're linking to does not start with a
/
. If you linked to/myorders
, it would appear to work the same, but if you ever wanted to deploy the app to a non-root URL the the link would break. The<base href="/">
tag inindex.html
specifies the prefix for all non-slash-prefixed URLs in the app, regardless of which component renders them.
If you run the app now, you'll see the link, styled as expected:
This shows it's not strictly necessary to use <NavLink>
. We'll see the reason to use it momentatily.
If you click "My Orders", nothing will seem to happen. Open your browser's dev tools and look in the JavaScript console. You should see that attempting to navigate to myorders
produces an error similar to:
Error: System.InvalidOperationException: 'Router' cannot find any component with a route for '/myorders'.
As you can guess, we will fix this by adding a component to match this route. Create a file in the Pages
folder called MyOrders.cshtml
, with the following content:
@page "/myorders"
<div class="main">
My orders will go here
</div>
Now when you run the app, you'll be able to visit this page:
Look closely at the top bar. Notice that when you're on "My orders", the link isn't highlighted in yellow. How can we highlight links when the user is on them? By using a <NavLink>
instead of a plain <a>
tag. The only thing a NavLink
does is toggle its own active
CSS class depending on whether it matches the current navigation state.
Replace the <a>
tag you just added in MainLayout
with the following (which is identical apart from the tag name):
<NavLink href="myorders" class="nav-tab">
<img src="img/bike.svg" />
<div>My Orders</div>
</NavLink>
Now you'll see the links are correctly highlighted according to navigation state:
Switch back to the MyOrders
component code. Once again we're going to inject an HttpClient
so that we can query the backend for data. Add the following under the @page
directive line:
@inject HttpClient HttpClient
Then add a @functions
block that makes an asynchronous request for the data we need:
@functions {
List<OrderWithStatus> ordersWithStatus;
protected override async Task OnParametersSetAsync()
{
ordersWithStatus = await HttpClient.GetJsonAsync<List<OrderWithStatus>>("/orders");
}
}
Let's make the UI display different output in three different cases:
- While we're waiting for the data to load
- If it turns out that the user has never placed any orders
- If the user has placed one or more orders
It's simple to express this using @if/else
blocks in Razor code. Update your component code as follows:
@page "/myorders"
@inject HttpClient HttpClient
<div class="main">
@if (ordersWithStatus == null)
{
<text>Loading...</text>
}
else if (ordersWithStatus.Count == 0)
{
<h2>No orders placed</h2>
<a class="btn btn-success" href="">Order some pizza</a>
}
else
{
<text>TODO: show orders</text>
}
</div>
@functions {
List<OrderWithStatus> ordersWithStatus;
protected override async Task OnParametersSetAsync()
{
ordersWithStatus = await HttpClient.GetJsonAsync<List<OrderWithStatus>>("/orders");
}
}
Perhaps some parts of this code aren't obvious, so let's point out a few things.
<text>
is not an HTML element at all. Nor is it a component. Once the MyOrders
component is compiled, the <text>
tag won't exist in the result at all.
<text>
is a special signal to the Razor compiler that you want to treat its contents as a markup string and not as C# source code. It's only used on rare occasions where the syntax would otherwise be ambiguous.
If <a href="">
(with an empty string for href
) surprises you, remember that the browser will prefix the <base href="/">
value to all non-slash-prefixed URLs. So, an empty string is the correct way to link to the client app's root URL.
The asynchronous flow we've implemented above means the component will render twice: once before the data has loaded (displaying "Loading.."), and then once afterwards (displaying one of the other two outputs).
If you want to reset your database to see the "no orders" case, simply delete pizza.db
from the Server project and reload the page in your browser.
Now we have all the data we need, we can use Razor syntax to render an HTML grid.
Replace the <text>TODO: show orders</text>
code with the following:
<div class="list-group orders-list">
@foreach (var item in ordersWithStatus)
{
<div class="list-group-item">
<div class="col">
<h5>@item.Order.CreatedTime.ToLongDateString()</h5>
Items:
<strong>@item.Order.Pizzas.Count()</strong>;
Total price:
<strong>£@item.Order.GetFormattedTotalPrice()</strong>
</div>
<div class="col">
Status: <strong>@item.StatusText</strong>
</div>
<div class="col flex-grow-0">
<a href="myorders/@item.Order.OrderId" class="btn btn-success">
Track >
</a>
</div>
</div>
}
</div>
It looks like a lot of code, but there's nothing special here. It simply uses a @foreach
to iterate over the ordersWithStatus
and outputs a <div>
for each one. The net result is as follows:
If you click on the "Track" link buttons next to an order, the browser will attempt a client-side navigation to myorders/<id>
(e.g., http://example.com/myorders/37
). Currently this will just log an error because no component matches this route.
Once again we'll add a component to handle this. In the Pages
directory, create a file called OrderDetails.cshtml
, containing:
@page "/myorders/{orderId:int}"
<div class="main">
TODO: Show details for order @OrderId
</div>
@functions {
[Parameter] int OrderId { get; set; }
}
This code illustrates how components can receive parameters from the router by declaring them as tokens in the @page
directive. If you want to receive a string
, the syntax is simply {parameterName}
, which matches a [Parameter]
name case-insensitively. If you want to receive a numeric value, the syntax is {parameterName:int}
, as in the example above. The :int
is an example of a route constraint. Other route constraints are supported too.
If you're wondering how routing actually works, let's go through it step-by-step.
- When the app first starts up, code in
Startup.cs
tells the framework to renderApp
as the root component. - The
App
component (inApp.cshtml
) contains a<Router>
.Router
is a built-in component that interacts with the browser's client-side navigation APIs. It registers a navigation event handler that gets notification whenever the user clicks on a link. - Whenever the user clicks a link, code in
Router
checks whether the destination URL is within the same SPA (i.e., whether it's under the<base href>
value). If it's not, traditional full-page navigation occurs as usual. But if the URL is within the SPA,Router
will handle it. Router
handles it by looking for a component with a compatible@page
URL pattern. Each{parameter}
token needs to have a value, and the value has to be compatible with any constraints such as:int
.- If there's no matching component, it's an error. This will change in Blazor 0.8.0, which includes support for fallback routes (e.g., for custom "not found" pages).
- If there is a matching component, that's what the
Router
will render. This is how all the pages in your application have been rendering all along.
The OrderDetails
logic will be quite different from MyOrders
. Instead of simply fetching the data once when the component is instantiated, we'll poll the server every few seconds for updated data. This will make it possible to show the order status in (nearly) real-time, and later, to display the delivery driver's location on a moving map.
What's more, we'll also account for the possibility of OrderId
being invalid. This might happen if:
- No such order exists
- Or later, when we've implement authentication, if the order is for a different user and you're not allowed to see it
Before we can implement the polling, we'll need to add the following directives at the top of OrderDetails.cshtml
, typically directly under the @page
directive:
@using System.Threading
@inject HttpClient HttpClient
You've already seen @inject
used with HttpClient
, so you know what that is for. Plus, you'll recognize @using
from the equivalent in regular .cs
files, so this shouldn't be much of a mystery either. Unfortunately, Visual Studio does not yet add @using
directives automatically in Razor files, so you do have to write them in yourself when needed.
Now you can implement the polling. Update your @functions
block as follows:
@functions {
[Parameter] int OrderId { get; set; }
OrderWithStatus orderWithStatus;
bool invalidOrder;
CancellationTokenSource pollingCancellationToken;
protected override void OnParametersSet()
{
// If we were already polling for a different order, stop doing so
pollingCancellationToken?.Cancel();
// Start a new poll loop
PollForUpdates();
}
private async void PollForUpdates()
{
pollingCancellationToken = new CancellationTokenSource();
while (!pollingCancellationToken.IsCancellationRequested)
{
try
{
invalidOrder = false;
orderWithStatus = await HttpClient.GetJsonAsync<OrderWithStatus>($"/orders/{OrderId}");
}
catch (Exception ex)
{
invalidOrder = true;
pollingCancellationToken.Cancel();
Console.Error.WriteLine(ex);
}
StateHasChanged();
await Task.Delay(4000);
}
}
}
The code is a bit intricate, so be sure to go through it carefully and be sure to understand each aspect of it. Here are some notes:
- This use
OnParametersSet
instead ofOnInit
orOnInitAsync
.OnParametersSet
is another component lifecycle method, and it fires when the component is first instantiated and any time its parameters change value. If the user clicks a link directly frommyorders/2
tomyorders/3
, the framework will retain theOrderDetails
instance and simply update itsOrderId
parameter in place.- As it happens, we haven't provided any links from one "my orders" screen to another, so the scenario never occurs in this application, but it's still the right lifecycle method to use in case we change the navigation rules in the future.
- We're using an
async void
method to represent the polling. This method runs for arbitrarily long, even while other methods run.async void
methods have no way to report exceptions upstream to callers (because typically the callers have already finished), so it's important to usetry/catch
and do something meaningful with any exceptions that may occur. - We're using
CancellationTokenSource
as a way of signalling when the polling should stop. Currently it only stops if there's an exception, but we'll add another stopping condition later. - We need to call
StateHasChanged
to tell Blazor that the component's data has (possibly) changed. The framework will then re-render the component. There's no way that the framework could know when to re-render your component otherwise, because it doesn't know about your polling logic.
OK, so we're getting the order details, and we're even polling and updating that data every few seconds. But we're still not rendering it in the UI. Let's fix that. Update your <div class="main">
as follows:
<div class="main">
@if (invalidOrder)
{
<h2>Nope</h2>
<p>Sorry, this order could not be loaded.</p>
}
else if (orderWithStatus == null)
{
<text>Loading...</text>
}
else
{
<div class="track-order">
<div class="track-order-title">
<h2>
Order placed @orderWithStatus.Order.CreatedTime.ToLongDateString()
</h2>
<p class="ml-auto mb-0">
Status: <strong>@orderWithStatus.StatusText</strong>
</p>
</div>
<div class="track-order-body">
TODO: show more details
</div>
</div>
}
</div>
This accounts for the three main states of the component:
- If the
OrderId
value is invalid (i.e., the server reported an error when we tried to retrieve the data) - If we haven't yet loaded the data
- If we have got some data to show
The last bit of UI we want to add is the actual contents of the order. Update the <div class="track-order-body">
and add more content inside to iterate over the pizzas in the order and their toppings, rendering it all:
<div class="track-order-body">
<div class="track-order-details">
@foreach (var pizza in orderWithStatus.Order.Pizzas)
{
<p>
<strong>
@(pizza.Size)"
@pizza.Special.Name
(£@pizza.GetFormattedTotalPrice())
</strong>
</p>
<ul>
@foreach (var topping in pizza.Toppings)
{
<li>+ @topping.Topping.Name</li>
}
</ul>
}
<p>
<strong>
Total price:
£@orderWithStatus.Order.GetFormattedTotalPrice()
</strong>
</p>
</div>
</div>
Finally, you have a functional order details display!
The backend server will update the order status to simulate an actual dispatch and delivery process. To see this in action, try placing a new order, then immediately view its details.
Initially, the order status will be Preparing, then after 10-15 seconds will change to Out for delivery, then 60 seconds later will change to Delivered. Because OrderDetails
polls for updates, the UI will update without the user having to refresh the page.
If you deployed your app to production right now, bad things would happen. The OrderDetails
logic starts a polling process, but doesn't end it. If the user navigated through hundreds of different orders (thereby creating hundreds of different OrderDetails
instances), then there would be hundreds of polling processes left running concurrently, even though all except the last were pointless because no UI was displaying their results.
You can actually observe this chaos yourself as follows:
- Navigate to "my orders"
- Click "Track" on any order to get to its details
- Click "Back" to return to "my orders"
- Repeat steps 2 and 3 a lot of times (e.g., 20 times)
- Now, open your browser's debugging tools and look in the network tab. You should see 20 or more HTTP requests being issued every few seconds, because there are 20 or more concurrent polling processes.
This is wasteful of client-side memory and CPU time, network bandwidth, and server resources.
To fix this, we need to make OrderDetails
stop the polling once it gets removed from the display. This is simply a matter of using the IDisposable
interface.
In OrderDetails.cshtml
, add the following directive at the top of the file, underneath the other directives:
@implements IDisposable
Now if you try to compile the application, the compiler will complain:
error CS0535: 'OrderDetails' does not implement interface member 'IDisposable.Dispose()'
Resolve this by adding the following method inside the @functions
block:
void IDisposable.Dispose()
{
pollingCancellationToken?.Cancel();
}
The framework calls Dispose
automatically when any given component instance is torn down and removed from the UI.
Once you've put in this fix, you can try again to start lots of concurrent polling processes, and you'll see they no longer keep running after the component is gone. Now, the only component that continues to poll is the one that remains on the screen.
Right now, once users place an order, the Index
component simply resets its state and their order appears to vanish without a trace. This is not very reassuring for users. We know the order is in the database, but users don't know that.
It would be nice if, once the order is placed, you navigated to the details display for that order automatically. This is quite easy to do.
Switch back to your Index
component code. Add the following directive at the top:
@inject IUriHelper UriHelper
The IUriHelper
lets you interact with URIs and navigation state. It has methods to get the current URL, to navigate to a different one, and more.
To use this, update the PlaceOrder
code so it calls UriHelper.NavigateTo
:
async Task PlaceOrder()
{
await HttpClient.PostJsonAsync("/orders", order);
order = new Order();
UriHelper.NavigateTo("myorders");
}
Now as soon as the server accepts the order, the browser will switch to the "order details" display and being polling for updates.
Next up - Refactor state management