Creating a Custom Menu Item Type

Out of the box, Orchard supports the following MenuItem types:

  • Content Menu Item
  • Custom Link
  • Html Menu Item
  • Query Link

Although the Custom Link menu item type would serve this purpose well, I prefer to be able to simply map menu items to controller actions and use tokenized route values in certain cases.
So in this post we'll have a look at what it takes to create a custom menu item type called Action Link:



This post assumes that you have setup a new Orchard installation (1.5.1 or higher) and that you have created an empty Orchard module to work with.

Defining the ActionLink

A menu item type in Orchard is essentially just a content type with the following characterstics:

  • It has the MenuPart and CommonPart attached
  • It has a setting called "Stereotype" with the "MenuItem" value
  • It has optionally a setting called "Description" with a short description describing the behavior of the menu item type

Because menu items are content items, we can completely control the behavior of these content items. One example is the Content menu Item that comes out of the box - it enables you to attach a content item with the menu item. Another example is the Query Menu Item, which enables you to select a Query to automatically generate child menu items.

In our case, we simply want to specify an Action name, Controller name, Area name and optionally some RouteValues.
When our menu item is rendered, we will use these properties to setup the url.

ActionLinkPart

To store the values of our custom ActionLink menu item type, we need to define a content part: ActionLinkPart.
We have the option of attaching content fields to this part, or we can create a table to store our part's values.

Let's go with the latter option and start with creating two new classes called ActionLinkPartRecord and ActionLinkPart<ActionLinkPartRecord>.

Models/ActionLinkPart.cs:

using Orchard.ContentManagement;
using Orchard.ContentManagement.Records;

namespace Contrib.Navigation.Models {
    public class ActionLinkPart : ContentPart<ActionLinkPartRecord> {
        public string ActionName {
            get { return Record.ActionName; }
            set { Record.ActionName = value; }
        }

        public string ControllerName {
            get { return Record.ControllerName; }
            set { Record.ControllerName = value; }
        }

        public string AreaName {
            get { return Record.AreaName; }
            set { Record.AreaName = value; }
        }

        public string RouteValues {
            get { return Record.RouteValues; }
            set { Record.RouteValues = value; }
        }
    }

    public class ActionLinkPartRecord : ContentPartRecord {
        public virtual string ActionName { get; set; }
        public virtual string ControllerName { get; set; }
        public virtual string AreaName { get; set; }
        public virtual string RouteValues { get; set; }
    }
}

Since we're storing content part data ourself we need to create a table for it. the perfect place to do that is using a Migration:

Migrations.cs:

using Orchard.ContentManagement.MetaData;
using Orchard.Core.Contents.Extensions;
using Orchard.Data.Migration;

namespace Contrib.Navigation {
    public class MenuMigrations : DataMigrationImpl {

        public int Create() {

            // Create the ActionLinkPartRecord table
            SchemaBuilder.CreateTable("ActionLinkPartRecord", table => table
                .ContentPartRecord()
                .Column<string>("ActionName", c => c.WithLength(256))
                .Column<string>("ControllerName", c => c.WithLength(256))
                .Column<string>("AreaName", c => c.WithLength(256))
                .Column<string>("RouteValues", c => c.WithLength(256)));

            // Define the ActionLinkPart
            ContentDefinitionManager.AlterPartDefinition("ActionLinkPart", part => part
                .Attachable(false));

            // Define the ActionLink content type and set it up to turn it into a menu item type
            ContentDefinitionManager.AlterTypeDefinition("ActionLink", type => type
                .WithPart("ActionLinkPart")     // Our custom part that will hold the Action, Controller, Area and RouteValues information
                .WithPart("MenuPart")           // Required so that the Navigation system can attach our custom menu items to a menu
                .WithPart("CommonPart")         // Required, contains common informatin such as the owner and creation date of our type. Many modules depend on this part being present
                .WithPart("IdentityPart")       // To support import / export, our type needs an identity since we won;t be providing one ourselves
                .DisplayedAs("Action Link")     // Specify the name to be displayed to the admin user

                // The value of the Description setting will be shown in the Navigation section where our custom menu item type will appear
                .WithSetting("Description", "Represents a custom link with a text and an action, controller and routevalues.")

                // Required by the Navigation module
                .WithSetting("Stereotype", "MenuItem")

                // We don't want our menu items to be draftable
                .Draftable(false)

                // We don't want the user to be able to create new ActionLink items outside of the context of a menu
                .Creatable(false)
                );

            return 1;
        }
    }
}

In order for the user to be able to manage the ActionLinkPart, we need to create a driver, which will be responsible for generating the editor shape and handling the post back of the part values.

Drivers/ActionLinkPartDriver.cs:

using Contrib.Navigation.Models;
using JetBrains.Annotations;
using Orchard.ContentManagement;
using Orchard.ContentManagement.Drivers;
using Orchard.ContentManagement.Handlers;

namespace Contrib.Navigation.Drivers {
    [UsedImplicitly]
    public class ActionLinkPartDriver : ContentPartDriver<ActionLinkPart> {
        
        // Best practice: always specify a Prefix to avoid potential input name conflicts
        protected override string Prefix {
            get { return "ActionLink"; }
        }

        protected override DriverResult Editor(ActionLinkPart part, dynamic shapeHelper) {
            return ContentShape("Parts_ActionLink_Edit", () => shapeHelper.EditorTemplate(TemplateName: "Parts/ActionLink", Model: part, Prefix: Prefix));
        }

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

        // Add support for exporting our part
        protected override void Exporting(ActionLinkPart part, ExportContentContext context) {
            context.Element(part.PartDefinition.Name).SetAttributeValue("ActionName", part.ActionName);
            context.Element(part.PartDefinition.Name).SetAttributeValue("ControllerName", part.ControllerName);
            context.Element(part.PartDefinition.Name).SetAttributeValue("AreaName", part.AreaName);
            context.Element(part.PartDefinition.Name).SetAttributeValue("RouteValues", part.RouteValues);
        }

        // Add support for importing our part
        protected override void Importing(ActionLinkPart part, ImportContentContext context) {
            context.ImportAttribute(part.PartDefinition.Name, "ActionName", x => part.ActionName = x);
            context.ImportAttribute(part.PartDefinition.Name, "ControllerName", x => part.ControllerName = x);
            context.ImportAttribute(part.PartDefinition.Name, "AreaName", x => part.AreaName = x);
            context.ImportAttribute(part.PartDefinition.Name, "RouteValues", x => part.RouteValues = x);
        }

    }
}

As you may have noticed, we didn't implement the Display method, since our driver doesn't need to display anything - that's being taken care of by the Navigation module.
We do need to implement the Importing and Exporting methods if we want to enable our content part to be exported and imported.

The template for the editor shape looks like this:

Views/EditorTemplates/Parts/ActionLink.cshtml:

@model Contrib.Navigation.Models.ActionLinkPart
@Display.TokenHint()
<fieldset>
    <div>
        <label for="@Html.FieldIdFor(m => m.ActionName)">@T("Action")</label>
        @Html.TextBoxFor(m => m.ActionName, new { @class = "text-box textMedium" })
        <div class="hint">the name of the action</div>
    </div>
    <div>
        <label for="@Html.FieldIdFor(m => m.ControllerName)">@T("Controller")</label>
        @Html.TextBoxFor(m => m.ControllerName, new { @class = "text-box textMedium" })
        <div class="hint">the name of the controller</div>
    </div>
    <div>
        <label for="@Html.FieldIdFor(m => m.AreaName)">@T("Area")</label>
        @Html.TextBoxFor(m => m.AreaName, new { @class = "text-box textMedium" })
        <div class="hint">the name of the area</div>
    </div>
    <div>
        <label for="@Html.FieldIdFor(m => m.RouteValues)">@T("Route Values")</label>
        @Html.TextBoxFor(m => m.RouteValues, new { @class = "large text tokenized text-box" })
        <div class="hint">Specify zero or more key/value pairs separated by a comma. Both keys and values can be entered as tokens, i.e.: id = {Request.QueryString:id}, returnUrl = ~/ </div>
    </div>
</fieldset>

Note the second line:

@Display.TokenHint()

The TokenHint shape is defined in the Orchard.Tokens module, and basically includes some required javascripts, styles and initialization to support tokenized text fields.
To tokenize a text field, simply add the "tokenized" css class.

To have this shape actually be rendered somewhere, we need to specify its placement using Placement.info:

Placement.info:

<Placement>
  <Place Parts_ActionLink_Edit="Content:0" />  
</Placement>


Finally, we need to add a StorageFilter so that Orchard will be able to load and store ActionLinkParts:

Handlers/ActionLinkPartHandler.cs:

using Contrib.Navigation.Models;
using Orchard.ContentManagement.Handlers;
using Orchard.Data;

namespace Contrib.Navigation.Handlers {
    public class ActionLinkPartHandler : ContentHandler {
        
        public ActionLinkPartHandler(IRepository<ActionLinkPartRecord> repository) {
            Filters.Add(StorageFilter.For(repository));
        }
    }
}

Aside: Tokens

Tokens are a very powerful feature. Essentially, they enable the user to use placeholder text instead of hard values. These placeholders will be resolved to actual values at runtime. Two modules that rely heavily on Tokens are the Autoroute and Projections modules.
Supporting tokens in our RouteValues property of the ActionLinkPart enables the user to include runtime values, for example a querystring value.

Implementing the custom behavior

Although we are now able to add menu items of type ActionLink, the url will be simply the Display url for the content item. That we don't want.
To change the Display url, we need to implement the GetItemMetadata method in our content handler, from where we will be able to use the action name, controller name, area name and route values to construct the new url.
Let's see how that works:

Handlers/ActionLinkPartHandler.cs (revised):

using System;
using System.Web.Routing;
using Contrib.Navigation.Models;
using Orchard.ContentManagement;
using Orchard.ContentManagement.Handlers;
using Orchard.Data;
using Orchard.Tokens;

namespace Contrib.Navigation.Handlers {
    public class ActionLinkPartHandler : ContentHandler {
        private readonly ITokenizer _tokenizer;

        public ActionLinkPartHandler(IRepository<ActionLinkPartRecord> repository, ITokenizer tokenizer) {
            _tokenizer = tokenizer;
            Filters.Add(StorageFilter.For(repository));
        }

        // Override GetItemMetadata so that we can override the default DisplayRouteValues
        protected override void GetItemMetadata(GetContentItemMetadataContext context) {

            // If requested for a content type other than ActionLink, return
            if (context.ContentItem.ContentType != "ActionLink")
                return;

            // Cast the content item to an ActionLinkPart so we can use its values
            var actionLinkPart = context.ContentItem.As<ActionLinkPart>();

            // Setup a RouteValuesDictionary
            var displayRouteValues = GetRouteValues(actionLinkPart);

            // Set MVC route values based on the values from our ActionLinkPart
            displayRouteValues["area"] = actionLinkPart.AreaName;
            displayRouteValues["controller"] = actionLinkPart.ControllerName;
            displayRouteValues["action"] = actionLinkPart.ActionName;

            // Set the constructed route values dictionary to the DisplayRouteValues of the passed context. The caller of this method will use it to generate the display url
            context.Metadata.DisplayRouteValues = displayRouteValues;
        }

        // A helper method that will convert the RouteValues string property value into a dictionary, tokenizing the values
        private RouteValueDictionary GetRouteValues(ActionLinkPart part) {
            var routeValues = new RouteValueDictionary();
            var routeValuesText = part.RouteValues;

            if (!string.IsNullOrWhiteSpace(routeValuesText)) {
                var items = routeValuesText.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
                foreach (var item in items) {
                    var pair = item.Split(new[] { '=' }, StringSplitOptions.RemoveEmptyEntries);

                    if (pair.Length == 2) {
                        var key = pair[0].Trim();
                        var value = pair[1].Trim();

                        // Tokenizing in action!
                        value = _tokenizer.Replace(value, null, new ReplaceOptions { Encoding = ReplaceOptions.NoEncode });

                        routeValues[key] = value;
                    }
                }
            }

            return routeValues;
        }
    }
}

 

Although that looks like a lot of code, all we are really doing is settin up a RouteValuesDictionary based on the action, controller, area and route values. Most of the code is required to turn the ActionLinkPart.RouteValues property value into a RouteValuesDictionary while tokenizing each value.

In order for this code to compile, be sure to add a reference to the Orchard.Tokens module. and of course update the Module.txt file:

Module.txt:

Name: Contrib.Navigation
AntiForgery: enabled
Author: DarkSky Development
Website: http://skywalkersoftwaredevelopment.net
Version: 1.0
OrchardVersion: 1.5.1
Description: Adds a new ActionLink menu item type to the system
Features:
    Contrib.Navigation:
        Description: Adds a new ActionLink menu item type to the system.
        Dependencies: Orchard.Tokens

And that's it! Now let's see our work in action by creating a new "Sign In" menu item and map it to the LogOn action of the Account controller of the Orchard.Users module.
To test that tokens work as well we'll add a dummy token to the RouteValues field.

1. Enable the module


2. Create a new Action Link menu item.

 

3. Try it out on the front-end (be sure to sign out first, or else you will be redirected to the home page and it will look like as if nothing happens when you click the Sign In menu item)


 

4. Try appending a querystring parameter to the address and checkout the url of the Sign In menu item

Conclusion

In this post we saw that Menu Items are just content types with some specific requirements: they require the MenuPart and the Stereotype setting to be "MenuItem".
To create a menu item with custom behavior, you can use custom content parts. Although I didn't mention this, another way to customize the menu item being rendered is by implementing INavigationFilter.
A great example can be found in the Projections module (look for NavigationQueryProvider inside the "Navigation" folder). Projections uses INavigationFilter to dynamically generate child menu items.

We also had a quick look at how to add support for tokenized values using ITokenizer and how to setup a tokenized text field using the "tokenized" css class and rendering the TokenHint shape.
Tokens are a powerful feature of Orchard that opens up advanced scenarios using a simple to use API.

Source can be found at: https://contriborchardnav.codeplex.com/

14 comments

  • ... Posted by GadgetMadGeek Posted 11/07/2014 07:58 AM

    Looking forward to the next article, keep up the good work. It's great to see some 'practical' Orchard demonstrations.

  • ... Posted by Sipke Schoorstra Posted 11/07/2014 07:59 AM [http://www.ideliverable.com]

    Thanks, glad you like it!

    As a matter of fact, since Orchard is really just an ASP.NET MVC application (be it a rather advanced one), it is perfectly capable of using the session. Orchard uses Forms Authentication to track the logged in user. Forms Authentication by default uses a cookie to track the user, but can be configured to use a cookieless method by storing the session identitifer inside of the url.

    In any case, there are definitely other options you could choose to store the shoppngcart data. One being cookies, since the data being stored is very simple.

    Another option could be to store the shopping cart data inside the Customer record (which we have yet to discus by the way). However, that obviously only works if there is actually a customer signed in into the website (unless we store unauthenticated user entries in the database, but I'm not a big fan of that).

    The safest way I think is storing the cart in a cookie, because that way the data won't get lost during an application pool recycle. Once a user is signed in, I then store the shoppingcart data in the database.

    Perhaps I will update this post that demonstrates how to use the cookies approach as well as storing the shoppingcart data in the database whenever we have a signed in user.

  • ... Posted by Piedon Posted 11/07/2014 07:59 AM [http://www.lombiq.com]

    Excellent series! I wonder if there are better ways to store shopping cart data than the session? I think Orchard is not using session at all but is able to track the logged in user somehow...

  • ... Posted by Piedone Posted 11/07/2014 08:00 AM [http://www.lombiq.com]

    I see, thanks. Have you considered using the approach of storing a unique, random identifier of the shopping "session" (session used not in technical sense here) in the session or in a cookie, not the whole whopping cart, than storing the shopping cart in the DB, indexed by this identifier? BTW will you release the module in the gallery?

  • ... Posted by Sipke Schoorstra Posted 11/07/2014 08:00 AM [http://www.ideliverable.com]

    I considered it, but doing that takes a bit more work without any real gain (unless I am missing something here). For example, the database will get polluted as visitors create shoppingcarts, because the session or cookie will eventually get lost, but the database records containing shoppingcart items will remain in store.

    Perhaps you dislike the idea of storing the shoppingcart items themselves into a session or a cookie. But consider the fact that a ShoppingCartItem only consists of 2 integers: a product ID and a Quantity. Storing this in a session requires very little space; most of the time even less than a random identifier (unless you use a PK value of type int32 of course).

    Because the shoppingcart data is so small, I think the simplicity of storing the shoppingcart data directly in a session or cookie (or even in a column of the customer record) outweighs saving a few bytes on cookie or session data. You do have a valid point however, when a customer tries to add over 75 unique items to his shopping cart, because that would mean the serialized data would take about 4.7 kb, which is too large for a cookie.

    In any case, perhaps the ShoppingCart should be modified in such a way that the persistance mechanism should be delegated to another class: for example, an abstract ShoppingCartPersistenceProvider with a few default implementations. That way, we all have a choice to implement storage in a way we think is best for our situation.

    Yes - the webshop module will be published into the gallery at the end of this tutorial as well as be uploaded to Codeplex.

  • ... Posted by Piedone Posted 11/07/2014 08:01 AM [http://www.lombiq.com]

    I'm pretty new to ASP.NET, so have no real experience with sessions on this platform, but I've read some advices for not using sessions, so was just curious about your decisions. Thanks for the explanation!

  • ... Posted by Piedone Posted 11/07/2014 08:01 AM [http://www.lombiq.com]

    Bertrand here: http://orchard.codeplex.com/discussions/254456 also explains some implications of using sessions considering a web farm environment, that might not be closely related in this case.

  • ... Posted by Sipke Schoorstra Posted 11/07/2014 08:02 AM [http://www.ideliverable.com]

    True, when running in a web farm you need to configure shared session state. Thanks for sharing that.

  • ... Posted by zgofmontreal Posted 11/07/2014 08:02 AM

    very clear and very help. thanks very much

  • ... Posted by Snygging_ Posted 11/07/2014 08:03 AM

    Really good, real world example!

  • ... Posted by Carl Berisford-Murray Posted 11/07/2014 08:03 AM

    Nice series. Thanks! We did something similar in about July last year and it's refreshing to see that a lot of your decisions were the same as ours :) But definitely some improvements from our side.

    Following on some of the other comments - we stored the cart in the database and referenced it by the username. We also used anonymous profiles (which create a GUID username) for anonymous carts which worked well - after a little bit of logic to upgrade anonymous carts to 'logged in' ones. This also had the advantage of never forgetting anything that a customer placed in their cart.

  • ... Posted by Sipke Schoorstra Posted 11/07/2014 08:03 AM [http://www.ideliverable.com]

    Glad to hear it! I have a question about the anonymous profile. Where did you store the GUID? Using a cookie, or a session variable?

    I love the idea of upgrading from anonyous carts to logged in ones, and will definitely include that in this tutorial in the upcomging parts.

  • ... Posted by Carl Berisford-Murray Posted 11/07/2014 08:04 AM

    Had to look over the code :) We stored the shopping cart in the database with a unique key of the username. We then attached the items to the basket. All of the shopping cart code is 'back end' and stored in the database, using AJAX for front end updates. The GUID is generated and accessed using ASP.NET's anonymous profile. It's here for a non-logged in user: HttpContext.Profile.UserName and it changes to the user's username once they have logged in. Very little work was required to implement it as Orchard uses forms authentication.

    HTH

  • ... Posted by Khalid Posted 02/07/2018 03:27 PM

    thanks for the tutorial .

    based on, how can i create custom item menu and attached it to a specific user Id ? so a user with different Id cannot sees the menu ?

Leave a comment