Writing an Orchard Webshop Module from scratch - Part 10

10. Managing the Customers and Orders from the backend

This is part 10 of a tutorial on writing a new Orchard module from scratch.
For an overview of the tutorial, please see the introduction.

In this part, we'll be handling the following:

  • We'll see how to add menu items to the admin menu;
  • We'll have a look at joining content queries;
  • We'll have a look at Orchard's paging utilities
  • We'll see how to create list and edit screens

Download the source code

Whenever we choose a platform to build upon, we want to be able to easily integrate our own modules into the backend so that we can manage the system that we're building. 

With Orchard, that could not be any easier. All it takes is writing some controllers, actions and views, with here and there some integration code to unleash the power of Orchard.

To demonstrate how it works, we'll extend the Admin with a menu item to manage Customers and Orders. We'll then show a searchable list of all customers and an edit screen where we can review and update customer info.

Adding menu items to the admin menu

Whenever you want add menuitems to the admin, you'll want to implement the INavigationProvider interface which lives in the Orchard.UI.Navigation namespace.
Orchard instantiates all implementations of INavigationProvider when it needs to build a menu (which is called by name). The name of the Admin menu is apropriately called "admin".

INavigationProvider only has two members:

public interface INavigationProvider : IDependency {
        string MenuName { get; }
        void GetNavigation(NavigationBuilder builder);
    }

We'll implement MenuName to return the string "admin", since Orchard will call a BuildMenu method from somewhere and passing "admin" as the name argument when it's building the admin navigation menu.
The navigation system will then select all implementations of INavigationProvider with a matching MenuName. 

GetNavigation receives a NavigationBuilder argument which we use to create one or more menu items.

We'll go ahead and create a new class in the root of our project called AdminMenu:

AdminMenu.cs:

using Orchard.Localization;
using Orchard.UI.Navigation;
 
namespace Orchard.Webshop {
    public class AdminMenu : INavigationProvider
    {
        public string MenuName {
            get { return "admin"; }
        }
 
        public AdminMenu() {
            T = NullLocalizer.Instance;
        }
 
        private Localizer T { getset; }
 
        public void GetNavigation(NavigationBuilder builder) {
            builder
                
                // Image set
                .AddImageSet("webshop")
 
                // "Webshop"
                .Add(item => item
 
                    .Caption(T("Webshop"))
                    .Position("2")
                    .LinkToFirstChild(true)
                
                    // "Customers"
                    .Add(subItem => subItem
                        .Caption(T("Customers"))
                        .Position("2.1")
                        .Action("Index""CustomerAdmin"new { area = "Orchard.Webshop" })
                    )
 
                    // "Orders"
                    .Add(subItem => subItem
                        .Caption(T("Orders"))
                        .Position("2.2")
                        .Action("Index""OrderAdmin"new { area = "Orchard.Webshop" })
                    )
                );
        }
    }
}


What we're doing here is a couple of things.

First we're adding a so called image set. An image set is actually nothing more than a name that you specify. Orhard uses it to automatically include a stylesheet named "menu.webshop-admin.css" which you'll have to create yourself.
The stylesheet should contain css rules that apply to the individual menu items.

Each menu item will be rendered with a certain css class. For example, the first menu item we are adding is called "Webshop". When it is rendered, the css class will be "navicon-webshop".
Similarily, the sub-menu items "Customers" and "Orders" will respectively get css classes "subnavicon-customers" and "subnavicon-orders". This enables us to style the menu items individually, for example by setting a background image as the menu item icon.

W're using the Position method of each item to determine the order in which they should be rendered. We're using "2" here for the main menu item "Webshop" to have it appear somewhere at the top of the admin menu.
We also set the Action for the two submenu items, which we'll create in a moment.

Let's go ahead and create a new css file in the styles folder:

Styles/menu.webshop-admin.css:

.navicon-webshop {
background-image:url('../images/menu.webshop.png') !important;
}
.navicon-webshop:hover {
background-position:0 -30px !important;
}

 

Since the stylesheet references an image file from the Images folder, we'll need to provide one.
Go ahead and save the following image into the "images" folder:

 

Our admin menu will now look something like this:

Admin controllers

Our two menu items "Customers" and Orders" currently link to non-existing actions. We'll fix that by creating two controllers for them: CustomerAdminController and OrderAdminController.
As you see, we're intermixing the word "Admin" into the controller's name just to make clear to ourselves that they handle admin related things. It's not a required convention.


We want the views returned from our admin controllers to be part of the admin UI rendering. Because the Admin UI is actually a theme itself, we could decorate our action methods with the ThemedAttribute as we did for the front end controllers. However, in order to activate the admin theme, Orchard provides an AdminAttribute which can be used to decorate a controller. Doing so will activate the TheAdmin theme and cause each view to be "themed", meaning that it will be injected into the content zone.
As an alternative, when your module requires only a single admin controller, you could name it AdminController. Orchard will recognize this and your views will then automatically be themed within the admin theme.

We'll continue and implement the CustomerAdminController and its Index action method. The Index action will simply display a list of Customers. We'll also make this list pageable, since we expect many hundreds of thousands of customers of course, and displaying them all on a single page is going to be troublesome.

Let's go ahead and create the CustomerAdminController:

Controllers/CustomerAdminController.cs:

using System;
using System.Linq;
using System.Web.Mvc;
using Orchard.ContentManagement;
using Orchard.DisplayManagement;
using Orchard.Settings;
using Orchard.UI.Admin;
using Orchard.UI.Navigation;
using Orchard.Users.Models;
using Orchard.Webshop.Helpers;
using Orchard.Webshop.Services;
using Orchard.Webshop.ViewModels;
 
namespace Orchard.Webshop.Controllers {
 
    [Admin]
    public class CustomerAdminController : Controller {
        private dynamic Shape { getset; }
        private readonly ICustomerService _customerService;
        private readonly ISiteService _siteService;
 
        public CustomerAdminController(ICustomerService customerService, IShapeFactory shapeFactory, ISiteService siteService) {
            Shape = shapeFactory;
            _customerService = customerService;
            _siteService = siteService;
        }
 
        public ActionResult Index(PagerParameters pagerParameters, CustomersSearchVM search) {
 
            // Create a basic query that selects all customer content items, joined with the UserPartRecord table
            var customerQuery = _customerService.GetCustomers().Join<UserPartRecord>().List();
 
            // If the user specified a search expression, update the query with a filter
            if (!string.IsNullOrWhiteSpace(search.Expression)) {
                
                var expression = search.Expression.Trim();
 
                customerQuery = from customer in customerQuery
                        where
                            customer.FirstName.Contains(expression, StringComparison.InvariantCultureIgnoreCase) ||
                            customer.LastName.Contains(expression, StringComparison.InvariantCultureIgnoreCase) ||
                            customer.As<UserPart>().Email.Contains(expression)
                        select customer;
            }
 
            // Project the query into a list of customer shapes
            var customersProjection = from customer in customerQuery
                                      select Shape.Customer
                                      (
                                        Id: customer.Id, 
                                        FirstName: customer.FirstName,
                                        LastName: customer.LastName,
                                        Email: customer.As<UserPart>().Email,
                                        CreatedAt: customer.CreatedAt
                                      );
 
            // The pager is used to apply paging on the query and to create a PagerShape
            var pager = new Pager(_siteService.GetSiteSettings(), pagerParameters.Page, pagerParameters.PageSize);
 
            // Apply paging
            var customers = customersProjection.Skip(pager.GetStartIndex()).Take(pager.PageSize);
 
            // Construct a Pager shape
            var pagerShape = Shape.Pager(pager).TotalItemCount(customerQuery.Count());
 
            // Create the viewmodel
            var model = new CustomersIndexVM(customers, search, pagerShape);
 
            return View(model);
        }
    }
}

It might look like a lot of code for just displaying a list of customers, but note that we're also including a paged and filtered query.
Let's go through it step by step:
 

// Create a basic query that selects all customer content items, joined with the UsePartRecord 
var customerQuery = _customerService.GetCustomers().Join<UserPartRecord>().List();

 

The interesting part here is the Join method: it's defined on the IContentQuery<T> interface which the call to _customerService.GetCustomers method returns:


Services/ICustomerService.cs:

IContentQuery<CustomerPart> GetCustomers();


Services/CustomerService.cs:

public IContentQuery<CustomerPart> GetCustomers() {
            return _orchardServices.ContentManager.Query<CustomerPartCustomerRecord>();
        }

The reason we're joining the CustomerRecord table with the UserPartRecord table is that we want to display the Email property of each customer. However, the CustomerPart does not define the Email property; it's the UserPart that defines it.
To avoid an extra database query per customer to get to the UserPart, we prefetch the UserPart records using the Join method.

The next lines of code modify the query if there is a search expression specified:

// If the user specified a search expression, update the query with a filter
            if (!string.IsNullOrWhiteSpace(search.Expression)) {
                
                var expression = search.Expression.Trim();
 
                customerQuery = from customer in customerQuery
                        where
                            customer.FirstName.Contains(expression, StringComparison.InvariantCultureIgnoreCase) ||
                            customer.LastName.Contains(expression, StringComparison.InvariantCultureIgnoreCase) ||
                            customer.As<UserPart>().Email.Contains(expression)
                        select customer;
            }

Note that in order to filter by Email, we are using the As<T> extension method to get to the UserPart. Since we joined the CustomerRecord with the UserPartRecord, we won't hit the database for each customer again when we execute this query.
Also note that we are using the Contains method using a StringComparison value. This overload of the System.String class doesn't exist in the .NET framework; I created an extension method for it in Helpers/StringExtensions:

Helpers/StringExtensions.cs:

public static bool Contains(this string source, string value, StringComparison comparison)
        {
            return source.IndexOf(value, comparison) >= 0;
        }

We could have used the IndexOf method directly in the query, of course, but this extension method makes it more intuitive.

 

The next lines create a new query that projects each result into a new shape that we call Customer:

// Project the query into a list of customer shapes
            var customersProjection = from customer in customerQuery
                                      select Shape.Customer
                                      (
                                        Id: customer.Id, 
                                        FirstName: customer.FirstName,
                                        LastName: customer.LastName,
                                        Email: customer.As<UserPart>().Email,
                                        CreatedAt: customer.CreatedAt
                                      );

This will just make it easier for our view to render a table of customers. The only reason we're using a dynamic shape instead of a strongly typed view model is flexibility: there's no view model class to maintain should we ever need to add or remove properties from the shape.

The following lines will create a new instance of the Pager class, which lives in Orchard.UI.Navigation.

// The pager is used to apply paging on the query and to create a PagerShape
var pager = new Pager(_siteService.GetSiteSettings(), pagerParameters.Page, pagerParameters.PageSize);

Its constructor takes an ISite instance, which is used to read the site's settings with regards to the default page size:

Since the user can override these settings from the view (which we receive via the PagerParameters argument), we also pass the pagerParameters.Page and pagerParameters.PageSize values into the constructor.
The Pager will use the site settings page size if the passed pageParameters.PageSize is null (which is the case when we initially invoke the Index action without specifying parameters).

The next line uses the Shape factory to create a new Pager shape, which is used to render the paging UI in our Index view:

// Construct a Pager shape
var pagerShape = Shape.Pager(pager).TotalItemCount(customerQuery.Count());

Finally, we create a new CustomersIndexVM view model which simply holds the list of customers, the pager and search model:

// Create the viewmodel
var model = new CustomersIndexVM(customers, search, pagerShape);
 
return View(model);

Note that we are using a strongly typed viewmodel this time instead of using a dynamic Shape.
I did this just to show that it doesn't really matter what you want to use as the view model: use whichever you prefer.

The CustomersIndexVM class looks like this:

ViewModels/CustomersIndexVM.cs:

using System.Collections.Generic;
using System.Linq;
 
namespace Orchard.Webshop.ViewModels {
    public class CustomersIndexVM {
        public IList<dynamic> Customers { getset; }
        public dynamic Pager { getset; }
        public CustomersSearchVM Search { getset; }
 
        public CustomersIndexVM() {
            Search = new CustomersSearchVM();
        }
 
        public CustomersIndexVM(IEnumerable<dynamic> customers, CustomersSearchVM search, dynamic pager) {
            Customers = customers.ToArray();
            Search = search;
            Pager = pager;
        }
    }
}

ViewModels/CustomersSearchVM.cs:

namespace Orchard.Webshop.ViewModels {
    public class CustomersSearchVM {
        public string Expression { getset; }
    }
}

Admittedly, using an entire viewmodel class for just a single search expression might be a bit of overkill. But should you at some stage want to include extra search options, it's easy to extend the class. On the other hand, you should strive to keep things as simpel as possible.

Next, we will create the Index view:

Views/CustomerAdmin/Index.cshtml:

@model Orchard.Webshop.ViewModels.CustomersIndexVM
@{
    Script.Require("ShapesBase");
    Layout.Title = T("Customers").ToString();
}
 
@using(Html.BeginForm("Index""CustomerAdmin"FormMethod.Get)) {
    <fieldset class="bulk-actions">
        <label for="search">@T("Search:")</label>
        @Html.TextBoxFor(m => m.Search.Expression)
        <button type="submit">@T("Search")</button>
        <a href="@Url.Action("Index")">@T("Clear")</a>
    </fieldset>
}
<fieldset>
    <table class="items" summary="@T("This is a table of the customers in your application")">
        <colgroup>
            <col id="Col1" />
            <col id="Col2" />
            <col id="Col3" />
            <col id="Col4" />
            <col id="Col5" />
            <col id="Col6" />
        </colgroup>
        <thead>
            <tr>
                <th scope="col">&nbsp;&darr;</th>
                <th scope="col">@T("FirstName")</th>
                <th scope="col">@T("LastName")</th>
                <th scope="col">@T("Email")</th>
                <th scope="col">@T("Created")</th>
                <th scope="col">@T("Actions")</th>
            </tr>
        </thead>
        @foreach (var customer in Model.Customers) {
        <tr>
            <td>@customer.Id</td>
            <td>@customer.FirstName</td>
            <td>@customer.LastName</td>
            <td>@customer.Email</td>
            <td>@customer.CreatedAt</td>
            <td>
                <div>
                    <a href="@Url.Action("Edit"new {customer.Id})" title="@T("Edit")">@T("Edit")</a>@T(" | ")
                    <a href="@Url.Action("List""AddressAdmin"new {customerId = customer.Id})" title="@T("Addresses")">@T("Addresses")</a>@T(" | ")
                    <a href="@Url.Action("Delete"new {customer.IdreturnUrl = Request.Url.PathAndQuery})">@T("Delete")</a>
                </div>
            </td>
        </tr>
        } 
    </table>
    @Display(Model.Pager)
</fieldset>

The interesting parts are the search form at the top and the pager at the bottom:

@using(Html.BeginForm("Index""CustomerAdmin"FormMethod.Get)) {
    <fieldset class="bulk-actions">
        <label for="search">@T("Search:")</label>
        @Html.TextBoxFor(m => m.Search.Expression)
        <button type="submit">@T("Search")</button>
        <a href="@Url.Action("Index")">@T("Clear")</a>
    </fieldset>
}

As you can see, we are using the "GET" http verb when submitting the form. This is quite practical, as it will cause the search parameter to be stored in the querystring, which will be included in the pager links as well.

@Display(Model.Pager)

That could not be easier, now could it?

The result should look something like this:

The next thing we'll want to do is create the edit screen for the customer. Doing so is quite simple, as Orchard provides us with some handy methods that will build an entire edit screen that includes any content parts or fields that the user may have added via the admin UI.
Let's see how it works.

First of all, we'll need an Edit action method to display the edit view:

Controllers/CustomerAdminController.cs:

public ActionResult Edit(int id) {
            var customer = _customerService.GetCustomer(id);
            var model = _contentManager.BuildEditor(customer);
 

The first line of the Edit method gets a customer by ID from the ICustomerService, which looks like this:

Services/ICustomerService.cs:

CustomerPart GetCustomer(int id);

Services/CustomerService.cs:

public CustomerPart GetCustomer(int id) {
            return _orchardServices.ContentManager.Get<CustomerPart>(id);
        }

The second line of the Edit action is simple but does something quite powerful: it invokes all of the drivers of all the content parts and fields of which the CustomerPart is part of:

var model = _contentManager.BuildEditor(customer);

IContentManager.BuildEditor basically gets the ContentItem property of the specified IContent object (being customer in our case), runs through all of its attached content parts, finds all the content part drivers for each of the content part, and then invokes the Editor method on each driver.
To get an instance of IContentManager, we just need to inject one into our controller's constructor:

public CustomerAdminController(... IContentManager contentManager) {

            _contentManager  = contentManager;
... 
        }

 

The final line simply returns a view, using the shape as its model:

// Casting to avoid invalid (under medium trust) reflection over the protected View method and force a static invocation.
return View((object)model);

Note that we're casting the dynamic model to object in order to avoid invalid reflection over the protected View method under medium trust.

The next thing to do is create the "Edit" view markup:

Views/CustomerAdmin/Edit.cshtml:

@{ Layout.Title = "Edit Customer"; }
 
@using(Html.BeginFormAntiForgeryPost())
{
    @Display(Model)
}

Since the UI is a shape, we can simply use the Display method of the view to render it. It wil even render a "Save" button for us. Talk about easy!

We do have to handle the postback ourselves though, so let's go back to the controller and create an EditPOST action method:

Controllers/CustomerAdminController.cs:

[HttpPostActionName("Edit")]
        public ActionResult EditPOST(int id)
        {
            var customer = _customerService.GetCustomer(id);
            var model = _contentManager.UpdateEditor(customer, this);
 
            if (!ModelState.IsValid)
                return View(model);
 
            _notifier.Add(NotifyType.Information, T("Your customer has been saved"));
            return RedirectToAction("Edit"new { id });
        }

Since we didn't specify to which action the form should be posted to, Orchard (or ASP.NET MVC) rendered the <form> element with its "action" attribute pointing to the current action, which is "Edit".
And since we named our postback action EditPOST, we need to annotate it with the ActionNameAttribute and attach the "Edit" action name to it.

The next thing we do is retrieve the customer to be updated, which we then pass into a call to IContentManager.UpdateEditor.
This method does almost the same thing as IContentManager.BuildEditor, with the main difference being that instead of calling Editor on each driver, it will call the overloaded version of Editor (the one which takes an IUpdateModel argument).
In the case of our CustomerDriver, that method looks like this:

Drivers/CustomerDriver.cs:

protected override DriverResult Editor(CustomerPart part, IUpdateModel updater, dynamic shapeHelper)
        {
            updater.TryUpdateModel(part, Prefix, nullnull);
 
            return Editor(part, shapeHelper);
        }

Going back to our controller, we see that _contentManager.UpdateEditor takes a second argument, to which we pass this, being the controller itself.
Looking at the signature of IContentManager.UpdateEditor:

dynamic UpdateEditor(IContent content, IUpdateModel updater, string groupId = "");


 

we see that the second argument is of type IUpdateModel, which looks like this:

public interface IUpdateModel {
        bool TryUpdateModel<TModel>(TModel model, string prefix, string[] includeProperties, string[] excludeProperties) where TModel : class;
        void AddModelError(string key, LocalizedString errorMessage);
    }

We will have this interface implemented by our own controller, since our controller already contains a TryUpdateModel<T> and AddModelError (via the ModelState property) method.
The implementation looks like this:

Controllers/CustomerAdminController.cs:

public class CustomerAdminController : Controller, IUpdateModel {

...

 bool IUpdateModel.TryUpdateModel<TModel>(TModel model, string prefix, string[] includeProperties, string[] excludeProperties) {
            return TryUpdateModel(model, prefix, includeProperties, excludeProperties);
        }
 
        void IUpdateModel.AddModelError(string key, LocalizedString errorMessage) {
            ModelState.AddModelError(key, errorMessage.Text);
        } 

    } 

So now we know where IUpdateModel comes from inside of our drivers.

The remainder of EditPOST looks like this:

if (!ModelState.IsValid)
                return View(model);
 
            _notifier.Add(NotifyType.Information, T("Your customer has been saved"));
            return RedirectToAction("Edit"new { id });

It simply checks if there are any model validation errors, and if so, returns the same view and model (which will be the shape as created by IContentManager.UpdateEditor.

We're also making use of the INotifier service to inform the user that the customer has been succesfully saved.
To make use of the INotifier service, simply take one as a dependency on the controller's constructor and store it in a private field:

private readonly INotifier _notifier; 

public CustomerAdminController(...INotifier notifier) {

            ...
            _notifier  = notifier;
        }

And finally, we redirect to the same Edit action again.

The edit screen should now look like this:

Notice that we're not only seeing all of the properties of the CustomerPart, but also the "Phone" field we attached to it via the Migrations file. Should our user add more parts and fields to the Customer content type, they will all show up here.

That said, we should also note that even though we attached the UserPart to the Customer content type, we're seeing no properties related to the UserPart whatsoever.
The reason for that is that there's no driver defined for the UserPart. Hence, no shapes will be created.

In our webshop, we do want to be able to update the Email property of the UserPart. We could do so by creating a UserPart driver ourselves, or we could just update our Parts/Customer.cshml template to include an Email edit field. Let's do just that:

Views/EditorTemplates/Parts/Customer.cshtml:

@model Orchard.Webshop.Models.CustomerPart
@{
    var user = Model.User;
}
<fieldset>
    ...
    
    <div class="editor-label">@Html.LabelFor(x => user.Email)</div>
    <div class="editor-field">
        @Html.TextBoxFor(x => user.Email, new { @class = "large text" })
        @Html.ValidationMessageFor(x => user.Email)
    </div>
</fieldset>

Notice how we are getting the UserPart from the CustomerPart using the User property. The User property is a convenience property we created ourselves:

Models/CustomerPart.cs:

public UserPart User {
            get { return this.As<UserPart>(); }
        }

Now that we have the UserPart, we can easily create an edit field for its Email property:

<div class="editor-label">@Html.LabelFor(x => user.Email)</div>
    <div class="editor-field">
        @Html.TextBoxFor(x => user.Email, new { @class = "large text" })
        @Html.ValidationMessageFor(x => user.Email)
    </div>

In order for changes to the Email field to be persisted, we need to update our Editor method of the CustomerPart driver:

Models/CustomerDriver.cs:

protected override DriverResult Editor(CustomerPart part, IUpdateModel updater, dynamic shapeHelper)
        {
            updater.TryUpdateModel(part, Prefix, nullnull);
 
            var user = part.User;
            updater.TryUpdateModel(user, Prefix, new[] {"Email"}, null);
 
            return Editor(part, shapeHelper);
        }

We simply invoke  the TryUpdateModel method on the user part as well, only this time specifying which properties to update ("Email"). This is not really required, since TryUpdateModel will only update the properties for which it can find values in the view value dictionary, but it's here if you ever need it.

When we refresh the browser and hit save, we see that it works as expected:

And that's all it takes to create an editor for any Content Item yourself!

However, let's go a step further and extend the editor by displaying some tabs where we can find the customer's addresses and orders.
As you know, some screens in Orchard contain a tabbed navigation system, such as the following:

What we're going to do is update our Edit Customer screen and have it display three tabs: Details, Addresses and Orders.
The Addresses and Orders tabs will display a list of addresses and orders for the current customer being edited.

In Orchard, the tabbed navigation system is called "LocalNav", and we can enable it per menu item from our AdminMenu class.
The interesting part in our case is that these tabs are contextual, meaning that the URL of the Addresses and Orders tabs will vary based on the current customer being edited.
Since Orchard will rebuild the menu on each request, we will be able to query the Route values and get the "id" route value and use that to build the URL.
Since the tabs will only be visible when we're editing a customer, the "id" will always correspond to that particular customer.

Let's see how it works. Open AdminMenu.cs and update it to contain the following code:

AdminMenu.cs:

using System.Web.Routing;
using Orchard.Environment;
using Orchard.Localization;
using Orchard.UI.Navigation;
 
namespace Orchard.Webshop {
    public class AdminMenu : INavigationProvider
    {
        private readonly Work<RequestContext> _requestContextAccessor;
 
        public string MenuName {
            get { return "admin"; }
        }
 
        public AdminMenu(Work<RequestContext> requestContextAccessor) {
            _requestContextAccessor = requestContextAccessor;
            T = NullLocalizer.Instance;
        }
 
        private Localizer T { getset; }
 
        public void GetNavigation(NavigationBuilder builder) {
 
            var requestContext = _requestContextAccessor.Value;
            var idValue = (string) requestContext.RouteData.Values["id"];
            var id = 0;
 
            if (!string.IsNullOrEmpty(idValue)) {
                int.TryParse(idValue, out id);
            }
 
            builder
                
                // Image set
                .AddImageSet("webshop")
 
                // "Webshop"
                .Add(item => item
 
                    .Caption(T("Webshop"))
                    .Position("2")
                    .LinkToFirstChild(true)
                
                    // "Customers"
                    .Add(subItem => subItem
                        .Caption(T("Customers"))
                        .Position("2.1")
                        .Action("Index""CustomerAdmin"new { area = "Orchard.Webshop" })
                        
                        .Add(T("Details"), i => i.Action("Edit""CustomerAdmin"new { id }).LocalNav())
                        .Add(T("Addresses"), i => i.Action("ListAddresses""CustomerAdmin"new { id }).LocalNav())
                        .Add(T("Orders"), i => i.Action("ListOrders""CustomerAdmin"new { id }).LocalNav())
                    )
 
                    // "Orders"
                    .Add(subItem => subItem
                        .Caption(T("Orders"))
                        .Position("2.2")
                        .Action("Index""OrderAdmin"new { area = "Orchard.Webshop" })
                    )
                );
        }
    }
}

There are a couple of interesting things here, and we'll step through them one at a time.
First of all, notice the new private field called _requestContextAccessor: 

private readonly Work<RequestContext> _requestContextAccessor;

This field will hold an instance of a Work<T> instance. The Work<T> class is very useful, because it enables us to request dependencies scoped per HTTP request.
We need access to the RequestContext in order to query the route values, from which we will retrieve the "id" of the current customer being edited.

When we scan down the file we see the following lines:

public void GetNavigation(NavigationBuilder builder) {
 
            var requestContext = _requestContextAccessor.Value;
            var idValue = (string) requestContext.RouteData.Values["id"];
            var id = 0;
 
            if (!string.IsNullOrEmpty(idValue)) {
                int.TryParse(idValue, out id);
            }
...

Inside the GetNavigation method, we're accessing the _requestContextAccessor using its Value property, which will actually resolve an instance of RequestContext for us.
We then use that RequestContext to get the "id" route value.
Note however that we are not checking if the current request is for our Edit Customer action, so we first check if there is any "id" value at all, and if so, we safely try to parse it into an integer.

Going down the code we then come acrosse the following lines:

.Add(T("Details"), i => i.Action("Edit""CustomerAdmin"new { id }).LocalNav())
.Add(T("Addresses"), i => i.Action("ListAddresses""CustomerAdmin"new { id }).LocalNav())
.Add(T("Orders"), i => i.Action("ListOrders""CustomerAdmin"new { id }).LocalNav())


Here we are adding a third level of sub menu items to the second level "Customers" menu item.
Notice the call to LocalNav at the end: this will set the LocalNav property of the menu item to true, causing the menu item to appear as a tab on our edit screen:

Implementing the Addresses and Orders screen are really just the same as implementing the Customers list and edit screens, so I'm not going to bore you with that code. Instead, you can download the source of this tutorial at the beginning of this post!

So as you see, extending the Orchard UI is pretty easy. All it really takes is writing some controllers and views, and be done with it.
What's more, building edit screens that involve content items is even more easy. Just use the IContentManager to build the edit shapes and render it!
 

In the next part, we will talk a bit on how to go about integrating with 3rd party systems. In many cases, your client will already have a complete ERP system in which products and customers are stored, and the client's whish is to enable these customers to buy the products using an online store, but without having to re-populate the webshop manually. Instead, the webshop should query the ERP, perhaps using a webservice, and cache the products and attach Orchard customers to the ERP customers.

In the next part, we'll have a look at DisplayTypes and how we can use them to change the rendering of the content items in the admin that have the ProductPart attached. As you may have noticed, each Book currently shows the "Add to shoppingcart" button, which isn't appropriate when you're in the admin, so that's what we're going to fix.

Tags: orchard, backend

21 Comments

  • Larry Bs said

    Just one thing of I have missed it... How to make the ContentType "Custom" (which is created by migration and not by Admin) to be visible in the list of "Content Items"?

  • treatment of gout said

    This acid is not properly excreted by the kidneys so it accumulates in the body.
    There are many medications for gout treatment although none of them are a cure.

  • toasters for sale uk said

    Remaining an additional advantage, when it is ice cold outside your skin care
    gets dry, absolutely nothing undesirable compared applying
    tickly humiliating any material to your anatomy ( blank
    ) no matter whether your going concerning night out putting in ventures inside the homes or alternatively while you are accommodations during the
    evening. Carry additional factors for example like weight, showcases, petrol exposure.
    Occupied element of the installations, materials and
    as well , sheet metal knives tend to be really just a no-no for microwave ovens.

  • vhs to dvd reviews said

    Howdy! I know this is kind of off topic but I was wondering if you knew where
    I could find a captcha plugin for my comment form?
    I'm using the same blog platform as yours and I'm having problems finding one?
    Thanks a lot!
    vhs to dvd reviews

  • Antonio Brown Jersey Nike said

    Leeds boss Brian McDermott said: We should have had two
    penalties in the NFL in antonio brown shoes, say, The Neutral-Arched Foot, The High-Arched Foot.
    I will miss that dearly. They failed to recognise Jesus for their long-awaited and long-expected Messiah,
    because he was faithful to God in Rome. The added ingredient of hope in your love is an inspiring,
    life-giving action. F C North Harrison 34 and Allen 30 each had five while Keisel 34 chipped in with two goals and five assists.

  • Euro 2012 Jerseys Ebay said

    A mere two days later he was supplanted by Tim Jennings and the Bears could easily bring him
    in cheap jerseys and move Israel Idonije to defensive tackle.

    The cheap jerseys haven't had a successful draft for six years.

  • Moncler Malmö said

    Use these with care ventilate the doudoune rouge moncler femme item very well before wearing and keep moth balls out of children's or
    pets' reach or use a fabric glue to finished the exposed
    edges. You can even emboss your logo right onto doudoune rouge moncler femme the wooden hanger, so that it centered and we have decorated the collar.
    If you need ample sizes, you can see that it's damp
    and the brush is for hitting those tight spots that
    the roller cannot reach.

  • Kathy said

    You need targeted visitors for your website so why not get some for free? There is a VERY POWERFUL and POPULAR company out there who now lets you try their website traffic service for 7 days free of charge. I am so glad they opened their traffic system back up to the public! Check it out here: http://2hams.com/51a

  • Michael Kors Handbags said

    an role model way to good your likelihood of you as a client.

    When shopping online, use reviews and ratings to your direction.
    You can get a big accessary to pit your different articulatio plana.
    Bracelets are fun and exciting. But if you are not trusty whether or not an seductive knock.
    Michael Kors Canada You can bring through hundreds all separate day.
    Staying involved can hold back manduction, painful,
    dig, and chasing urges. It can be secondhand one correct and move a squeaky-attribute executive department.
    The entropy enclosed in the event that you are buying new wear because you canbecause't bomb with a good, or if they don't

  • How To Lower Blood Pressure Quickly - Youtube.com said

    Can I just say what a comfort to find a person that
    truly knows what they are talking about over the internet.
    You definitely understand how to bring a problem to light and make it important.
    A lot more people ought to look at this and understand this
    side of the story. I was surprised that you are not
    more popular since you most certainly possess the gift.

  • Johnd740 said

    Outstanding post, you have pointed out some fantastic points, I as well think this is a very great website. cfbddedbegad

  • Johng163 said

    requirements. Recognitions pro suggestion like operative, balanced, explanatory as well as moreover exuberance thinkings about this issue to Gloria. gfcgacdccbce

  • Natalie said

    This is a message to the admin. Your website is missing out on at least 300 visitors per day. I have found a company which offers to dramatically increase your visitors to your site: http://qmm.is/14t They offer 500 free visitors during their free trial period and I managed to get over 15,000 visitors per month using their services, you could also get lot more targeted visitors than you have now. Hope this helps :) Take care.

  • concord 11s said

    cheap jordan 11 legend blue http://www.pinterest.com/RetroConcord11S/cheap-jordan-11-low-concord-for-sale/ <br/> jordan 11 legend blue http://www.pinterest.com/BelAir5ForCheap/retro-jordan-11-bred-for-cheap-sale-low-price/ <br/> bred 11s http://www.pinterest.com/RetroConcord11S/buy-jordan-11-low-concord-for-cheap-sale-2014/ <br/> cheap jordan 11 bred http://www.pinterest.com/BelAir5ForCheap/buy-cheap-jordan-11-gamma-blue-for-sale/ <br/> jordan 11 infrared for sale http://www.pinterest.com/laney5sforsale/gamma-blue-11s-cheap-sale-2013/ <br/> <a href="http://www.pinterest.com/laney5sforsale/gamma-blue-11s-cheap-sale-2013/"><strong>http://www.pinterest.com/laney5sforsale/gamma-blue-11s-cheap-sale-2013/</strong></a><br/> <a href="http://www.pinterest.com/RetroConcord11S/cheap-jordan-11-low-concord-for-sale/"><strong>legend blue 11s for sale</strong></a><br/><a href="http://www.pinterest.com/BelAir5ForCheap/retro-jordan-11-bred-for-cheap-sale-low-price/"><strong>aqua 11</strong></a><br/><a href="http://www.pinterest.com/RetroConcord11S/buy-jordan-11-low-concord-for-cheap-sale-2014/"><strong>jordan 11 aqua</strong></a><br/><a href="http://www.pinterest.com/BelAir5ForCheap/buy-cheap-jordan-11-gamma-blue-for-sale/"><strong>cheap jordan 11 legend blue</strong></a><br/><a href="http://www.pinterest.com/laney5sforsale/gamma-blue-11s-cheap-sale-2013/"><strong>cheap legend blue 11</strong></a><br/>[url=http://www.pinterest.com/RetroConcord11S/cheap-jordan-11-low-concord-for-sale/][b]cheap jordan 11 legend blue[/b][/url]<br/>[url=http://www.pinterest.com/BelAir5ForCheap/retro-jordan-11-bred-for-cheap-sale-low-price/][b]jordan 11 concord[/b][/url]<br/>[url=http://www.pinterest.com/RetroConcord11S/buy-jordan-11-low-concord-for-cheap-sale-2014/][b]jordan 11 low[/b][/url]<br/>[url=http://www.pinterest.com/BelAir5ForCheap/buy-cheap-jordan-11-gamma-blue-for-sale/][b]low concord 11[/b][/url]<br/>[url=http://www.pinterest.com/laney5sforsale/gamma-blue-11s-cheap-sale-2013/][b]jordan 11 low top snakeskin[/b][/url]<br/>toro bravo 4s http://www.pinterest.com/RetroConcord11S/cheap-jordan-4-green-glow-for-sale/ <br/> jordan 4 green glow for sale http://www.pinterest.com/BelAir5ForCheap/buy-cheap-jordan-4-bred-for-sale-low-price/ <br/> jordan 4 fear http://www.pinterest.com/RetroConcord11S/buy-retro-jordan-4-toro-bravo-for-cheap-sale/ <br/> cheap green glow 4s http://www.pinterest.com/BelAir5ForCheap/retro-jordan-4-fear-for-cheap-sale-low-price/ <br/> <a href="http://www.pinterest.com/BelAir5ForCheap/retro-jordan-4-fear-for-cheap-sale-low-price/"><strong>http://www.pinterest.com/BelAir5ForCheap/retro-jordan-4-fear-for-cheap-sale-low-price/</strong></a> <a href="http://www.pinterest.com/RetroConcord11S/cheap-jordan-4-green-glow-for-sale/"><strong>fear 4s</strong></a><br/><a href="http://www.pinterest.com/BelAir5ForCheap/buy-cheap-jordan-4-bred-for-sale-low-price/"><strong>toro bravo 4s</strong></a><br/><a href="http://www.pinterest.com/RetroConcord11S/buy-retro-jordan-4-toro-bravo-for-cheap-sale/"><strong>cheap jordan 4 fear</strong></a><br/><a href="http://www.pinterest.com/BelAir5ForCheap/retro-jordan-4-fear-for-cheap-sale-low-price/"><strong>toro bravo 4s</strong></a><br/>[url=http://www.pinterest.com/RetroConcord11S/cheap-jordan-4-green-glow-for-sale/][b]cheap fear 4s[/b][/url]<br/>[url=http://www.pinterest.com/BelAir5ForCheap/buy-cheap-jordan-4-bred-for-sale-low-price/][b]cheap jordan 4 bred[/b][/url]<br/>[url=http://www.pinterest.com/RetroConcord11S/buy-retro-jordan-4-toro-bravo-for-cheap-sale/][b]jordan 4 fear[/b][/url]<br/>[url=http://www.pinterest.com/BelAir5ForCheap/retro-jordan-4-fear-for-cheap-sale-low-price/][b]jordan 4 toro bravo for sale[/b][/url]<br/>jordan 5 oreo http://www.pinterest.com/RetroConcord11S/50-off-cheap-jordan-5-bel-air-for-sale/ <br/> fire red 5s http://www.pinterest.com/BelAir5ForCheap/cheap-jordan-5-oreo-for-sale-low-price/ <br/> jordan 5 fire red http://www.pinterest.com/RetroConcord11S/cheap-retro-jordan-oreo-5s-for-sale/ <br/> cheap jordan 5 3lab5 http://www.pinterest.com/BelAir5ForCheap/buy-cheap-jordan-5-bel-air-for-sale/ <br/> <a href="http://www.pinterest.com/RetroConcord11S/cheap-retro-jordan-oreo-5s-for-sale/"><strong>http://www.pinterest.com/RetroConcord11S/cheap-retro-jordan-oreo-5s-for-sale/</strong></a> <a href="http://www.pinterest.com/RetroConcord11S/50-off-cheap-jordan-5-bel-air-for-sale/"><strong>jordan 3lab5 restock</strong></a><br/><a href="http://www.pinterest.com/BelAir5ForCheap/cheap-jordan-5-oreo-for-sale-low-price/"><strong>jordan 5 bel air for sale</strong></a><br/><a href="http://www.pinterest.com/RetroConcord11S/cheap-retro-jordan-oreo-5s-for-sale/"><strong>3lab5</strong></a><br/><a href="http://www.pinterest.com/BelAir5ForCheap/buy-cheap-jordan-5-bel-air-for-sale/"><strong>cheap jordan 5 bel air</strong></a><br/>[url=http://www.pinterest.com/RetroConcord11S/50-off-cheap-jordan-5-bel-air-for-sale/][b]fire red 5s restock[/b][/url]<br/>[url=http://www.pinterest.com/BelAir5ForCheap/cheap-jordan-5-oreo-for-sale-low-price/][b]jordan 5 bel air[/b][/url]<br/>[url=http://www.pinterest.com/RetroConcord11S/cheap-retro-jordan-oreo-5s-for-sale/][b]3lab5[/b][/url]<br/>[url=http://www.pinterest.com/BelAir5ForCheap/buy-cheap-jordan-5-bel-air-for-sale/][b]cheap jordan 5 oreo[/b][/url]

  • football said

    He's not desperately trying to look like a big shot.
    Do you wear your own football package when actively playing football along with friends
    on the park or even wear your shirt casually around town or perhaps is
    this your ritual to wear your groups colors when going down to
    the pub. Many people are seeking out the latest Premiership news, as this
    is where much of the interest in English football is focused.

Add a Comment