Part 5: Resolving, routing, and multiple pages
This continues on from the work done in part 4, if you haven't completed that, it's best to head over there. Alternatively, you can switch to the branch "Part04Final" (Team Explorer - Branches - right click "Part04Final" inside remotes/origin and select "New local branch from..." and give it a local name, tick Checkout branch and untick Track remote branch) to use our version of part 4.
Resolving data before loading a page
In our home page controller, the first thing we do is load our list of items from the server. This is simple, but where you have data that is a pre-requisite for showing a page, it's not the best way to do things. The page router has "resolving" functionality that allows data to be loaded as a pre-requisite for displaying a particular page, then passed in as a dependency much like a service. Let's convert our thingList home page to resolve the current list of things instead of loading it after page load.
Open up the routes.js file, and update the route we specified earlier as follows:
$stateProvider.state('home', { url: '/', templateUrl: 'js/src/home/home.html', controller: 'Home as home', resolve: { thingList: ['thingListService', function(thingListService) { return thingListService.getThings(); }] } });
There's a few things going on here. The "resolve" property defines a set of
items which must be resolved before the page is displayed. In this case, we've
defined a single item, thingList
. The value of the item can be
one of three things - a hard-coded value, e.g. thingList: null
,
a function that returns a value directly, e.g. thingList: function() { return "horse"; }
,
or a function that returns a promise, which will be resolved before the page
is displayed - as per the example above.
The other thing to note here is the "inline dependency" syntax. This is an equivalent to the $inject property we've used elsewhere - the dependencies are listed in the array as per $inject, but the final item of the array is the function that uses the dependencies.
Now we've updated the route, let's update our controller to use the data
supplied to it. In Home.controller.js, you'll need to add "thingList" to the
$inject line, and to the parameters to the function. Then, simply set
vm.list = thingList;
and remove the call to refreshList() in the
initialisation section - so it should all look something like this:
Home.$inject = ["thingListService", "thingList"]; function Home(thingListService, thingList) { var vm = this; // "Public" properties vm.list = thingList; ... // Initialisation // refreshList(); -- no longer required, we're resolving it before // we display the view. ...
Run it up, add a couple of items then refresh the page - you should see the items in the list when refreshing - and you should not see the page load without the items present (with the previous version, you may have noticed a slight lag between the page loading and the list being populated).
Multiple pages!
So far, we've only created a single page in our application. Let's add a second page to display some details! This will give us the opportunity to try out a few of the things we've covered so far, and to cover a couple of new areas too.
Update the WebAPI
We've decided that we want to store a more complex representation of our Things than the current Web API allows, so the first job is to update our Web API. Let's start by updating the Thing model class to add in a bit of detail about that thing:
public class Thing { public string Value { get; set; } public string Detail { get; set; } }
We also need to update our ThingListController to store these in a static dictionary of Thing objects, instead of a list of strings, and update our endpoints. It should look something like the below, noting the following key changes:
- Returning IActionResult - instead of plain objects, we're now returning
IActionResults from most of our methods. This allows us to return HTTP status codes as well
as plain objects (wrapped in
new ObjectResult()
) - take a look to see how we're doing both of those. - Get collection - our basic
Get()
method for the entire collection is now returning the collection of Keys from our dictionary, rather than the previous string list. - Get detail - we've added a second
Get(string thing)
method which takes a URL including an identifier of a specific thing and returns the full representation of that single thing. - Put action - an HTTP PUT means "replace the representation at this
URL with what I've supplied in the body of this request" - i.e. update an existing
item. This completes our basic set of HTTP actions -
GET
retrieves (either a collection /api/thinglist or a specific item /api/thinglist/sheep),POST
creates (always to the collection address /api/thinglist),PUT
updates (always to a specific item /api/thinglist/sheep) andDELETE
deletes (also to a specific item /api/thinglist/sheep).
private static Dictionary<string, Thing> s_thingDetails = new Dictionary<string, Thing>(); // Get the IDs of all things [HttpGet] public IEnumerable<string> Get() { return s_thingDetails.Keys; } // Create a new thing [HttpPost] public IActionResult Post([FromBody]Thing thing) { // If this thing already exists, return a 409 Conflict. if (s_thingDetails.ContainsKey(thing.Value)) { return new HttpStatusCodeResult((int)HttpStatusCode.Conflict); } s_thingDetails[thing.Value] = thing; // Just return an empty OK to say we've done what was asked. return new HttpOkResult(); } // Get the full detail of an existing thing. [HttpGet("{thing}")] public IActionResult Get(string thing) { // If we don't know about thing, return a not found. if (!s_thingDetails.ContainsKey(thing)) return HttpNotFound(); // Return the full object. return new ObjectResult(s_thingDetails[thing]); } // Update an existing thing. [HttpPut("{thing}")] public IActionResult Put(string thing, [FromBody] Thing thingWithDetail) { // If we don't know about thing, return a not found. if (!s_thingDetails.ContainsKey(thing)) return HttpNotFound(); // Update the details using the thing we've had sent in. s_thingDetails[thing] = thingWithDetail; // Return a representation of what we've updated. return new ObjectResult(s_thingDetails[thing]); } // Delete an existing thing. [HttpDelete("{thing}")] public IActionResult Delete(string thing) { // If we don't know about thing, return a not found. if (!s_thingDetails.ContainsKey(thing)) return HttpNotFound(); s_thingDetails.Remove(thing); // Just return an empty OK to say we've done what was asked. return new HttpOkResult(); }
For reference, here is the HTTP endpoints that this API now provides:
URL | Verb | Meaning |
---|---|---|
/api/thinglist |
GET |
Return the names of things in our list Returns: HTTP 200 OK, body e.g. ["Thing1", "Thing2"]
|
/api/thinglist |
POST | Add a thing to the list. Request body: { "Value": "Sheep", "Detail": "Sheep's detail!" } Returns: HTTP 409 Conflict if value (e.g. "Sheep" in this case) already exists HTTP 200 OK if fine, no body. |
/api/thinglist/{thing} |
GET |
Returns detail of {thing}. Returns: HTTP 404 Not Found if {thing} doesn't exist. HTTP 200 OK, body e.g.: { "Value": "Sheep",
Detail: "Sheep's detail!" } |
/api/thinglist/{thing} |
PUT |
Updates detailed representation of {thing}. Request body: { "Value": "Sheep", "Detail": "Sheep's detail!" } Returns: HTTP 404 Not Found if {thing} doesn't exist. HTTP 200 OK if updated OK, body e.g. { "Value": "Sheep",
"Detail": "Sheep's detail!" }
|
/api/thinglist/{thing} |
DELETE |
Remove a particular thing from the list Returns: HTTP 404 Not Found if {thing} doesn't exist. HTTP 200 OK if removed OK, no body. |
At this point, our API is ready. Try it out from Postman to check that it works as expected.
Let's quickly update our thingList.serivce.js service to cover this updated API - this should all look
fairly familiar, the only major changes are that we're now doing some error handling for the specific HTTP
status codes that we have decided to return from our Web API, and we've added in our first use of the
$http.put()
function:
var service = { getThings: getThings, addThing: addThing, getThing: getThing, updateThing: updateThing, removeThing: removeThing }; // Return the interface. return service; // Define the functions referenced by the service interface // above here: function getThings() { return $http.get("/api/thinglist").then(function (response) { return response.data; }); } function addThing(thing) { return $http.post("/api/thinglist", { "Value": thing }).then( function (response) { return response.data; }, function (errResponse) { if (errReponse.status === 409) { throw new Error("AlreadyExists"); } else { // Re-throw for default handling. throw errResponse; } } ); } function getThing(thing) { return $http.get("/api/thinglist/" + thing).then( function (response) { return response.data; }, function (errResponse) { // If this was a not found, simply return null. if (errReponse.status === 404) { return null; } else { // Re-throw for default handling. throw errResponse; } } ); } function updateThing(thing, thingWithDetails) { return $http.put("/api/thinglist/" + thing, thingWithDetails).then( function (response) { return response.data; }, function (errResponse) { // If this was a not found, throw a specific error. if (errReponse.status === 404) { throw new Error("DoesntExist"); } else { // Re-throw for default handling. throw errResponse; } } ); } function removeThing(thing) { return $http.delete("/api/thinglist/" + thing).then( function (response) { return response.data; }, function (errReponse) { // If this was a not found, throw a specific error. if (errReponse.status === 404) { throw new Error("DoesntExist"); } else { // Re-throw for default handling. throw errResponse; } } ); }
We're now ready to add our second page.
Adding a second page to our application
We now want to add a page which will show the details of a single thing. It'll have its own controller and template, and the route will resolve the thing in question from our new Get endpoint.
Let's start by defining the new route in routes.js - this will look fairly similar to the existing route for "home", but will have a parameter in its URL to identify which "thing" we're interested in - this gets appended to the end of the existing state:
$stateProvider.state('home', { ... }).state('detail', { url: '/things/:thing', templateUrl: 'js/src/thingdetail/thingdetail.html', controller: 'ThingDetail as thingDetail', resolve: { thing: ["thingListService", "$stateParams", function (thingListService, $stateParams) { // The injected "$stateParams" gets its parameters // from the URL pattern defined above. return thingListService.getThing( $stateParams.thing); } ] } });
This looks very similar to our route for the "home" state, but with two notable differences -
the URL has a parameter within it (identified by :thing
) and the resolve
function has an additional injection - $stateParams - which has properties on it relating to
any parameters specified in the URL - in this case, a .thing property. We use that to
load the relevant data from the thingListService.
This is an important point to understand, so we'll work through an example of how this operates.
- The user visits
http://localhost:12345/things/chicken
(by typing it in or by following a link inside the application) - The URL will be analysed - Angular will identify our "detail" route as matching, and
create a stateParams with
$stateParams.thing = "chicken"
. - The specified resolve will be executed with this stateParams, which calls
thingListService.getThing("chicken")
. - The service will do an HTTP GET on
/api/thinglist/chicken
. - Once that has all completed, the resolve is complete and the requested detail page will be loaded.
Now we've got the route, we need to create the controller and template. Create a new folder in wwwroot/js/src named thingdetail, and put a new controller ThingDetail.controller.js and HTML template thingdetail.html. Put the controller boilerplate structure as per part 1, remembering to change the names to ThingDetail instead of Home.
In our new controller, we'll need to inject "thing" to receive the resolved data, and pop that in a new public property (e.g. vm.thing) - so overall it should look something like this:
(function () { 'use strict'; angular.module('app').controller('ThingDetail', ThingDetail); ThingDetail.$inject = ["thing"]; function ThingDetail(thing) { var vm = this; // "Public" properties vm.thing = thing; // "Public" functions // "Private" properties // Initialisation // "Public" function definitions // "Private" function definitions // Event Subscriptions } })();
In our new thingdetail.html template, let's show the name and detail from the Thing we've been passed - remember to use the alias for the controller that we defined in the route, and that the object passed in to the controller will be structured as per our Thing class on the server-side.
Once you've done that, you should end up with something like the below:
<table> <tr> <th>Name:</th> <td>{{thingDetail.thing.Value}}</td> </tr> <tr> <th>Detail:</th> <td>{{thingDetail.thing.Detail}}</td> </tr> </table> <p><a href="/">Back to Thing List</a></p>
I've added a link back to our "home" page at the bottom there, now we should update our thing list to link to these detail pages to complete the navigation around our app. In the agsThingList.html template, update the {{item}} line to wrap it in a link to our new detail page:
<ul> <li ng-repeat="item in agsThingList.list"> <a ng-href="/things/{{item}}">{{item}}</a> <span ng-hide="agsThingList.removing"> (<a href="#" ng-click="agsThingList.removeItem({item: item})">remove</a>) </span> </li> </ul>
Here, we're using ng-href
instead of a plain href, this is so Angular can
replace {{item}} correctly to form a valid URL.
Run this up, add an item or two, and you should be able to click on the item name and be taken to the detail page, showing the name you've entered on the previous page, and an empty detail. You should also be able to click on the Back to Thing List link and navigate back to our home page. If you look closely, you'll notice that the page isn't loading fully when you click these links - Angular is intercepting the clicks and loading the relevant states of the app internally, without an additional round-trip to the server - all that is actually being updated is the content inside the <ui-view> tag in Views/Home/Index.cshtml. The address bar URL is being updated as you move around, though, so if the user refreshes the page or sends the URL to a friend, they'll get the right "state" of the app when they load the URL.
Aside: Nested states
It is worth being aware that you can nest states inside each other. In this scenario, a top-level template would have another <ui-view> inside it, and child states which derive from that state would put their content there, and so forth. This is very powerful for building up a modular app.
To very quickly try this out, you need only to make a couple of changes to your routes.js file:
- Change the name of your 'detail' state to
home.detail
- this defines it as a child of home. - Remove the leading / from the URL for that state, i.e. it becomes
url: 'things/:thing'
- this is because it inherits the URL of its parent, so you are defining the URL relative to that.
Then you just need to pop <ui-view></ui-view> into your home.html template where you want the detail to be injected (I'd suggest under the Add section). Now, when you run your app, the detail will be shown on the same page as the list!
Undo this or leave it as is for the next section - either will work!
Making it editable
Now we've updated our API and created our new form for viewing the details, we should make it editable to use our Put function and allow us to specify details for a thing. To do this, let's start by changing our thingdetail.html to provide editing functionality:
<table> <tr> <th>Name:</th> <td>{{thingDetail.thing.Value}}</td> </tr> <tr> <th>Detail:</th> <td> <input type="text" ng-model="thingDetail.thing.Detail"/> <input type="button" ng-click="thingDetail.save()" value="Save" ng-disabled="thingDetail.saving"/> </td> </tr> </table> <p ng-show="thingDetail.errMsg">{{thingDetail.errMsg}}</p> <p><a href="/">Back to Thing List</a></p>
There shouldn't be anything too surprising there! We've used "ng-disabled", which can disable form
elements based on a boolean, to stop Save being clicked while the save is processing. We've also
defined a ng-click handler function - save() - so add that to our ThingDetail controller and make
it call our thingListService.updateThing
function. You should end up with something like
this:
ThingDetail.$inject = ["thing", "thingListService"]; function ThingDetail(thing, thingListService) { var vm = this; // "Public" properties vm.thing = thing; vm.saving = false; vm.errMsg = null; // "Public" functions vm.save = save; // "Private" properties // Initialisation // "Public" function definitions function save() { vm.saving = true; thingListService.updateThing(vm.thing.Value, vm.thing).then( function (updatedThing) { // In case the server has changed anything, // update our local thing (e.g. if we had an "updatedTime" // property managed by the server). vm.thing = updatedThing; vm.saving = false; }, // Catch any errors and display a message. function (err) { vm.saving = false; vm.errMsg = err.message; } ); } ... }
Note that what we're sending back to the server is what the server originally sent to us - vm.thing - which has been updated by being bound to the input in our template. This means that no matter how many properties of this object we want to edit in our form, our code here and in the service remains identical - the only work is to add the inputs in our template and bind them to the relevant properties.
We've also included a bit of rudimentary error handling in there too, should the update fail - nothing flashy at the moment! To see this in operation:
- Open your app and add an item
- Navigate to the details page for that item
- Open a second tab and go to your home page
- Remove the item using the (remove) link
- Go back to the first tab, and attempt to Save the details - you should see a DoesntExist message.
That's the end of this part, so if you're having trouble following any of the above, you can see our complete working solution in the Part05Final branch.