Tales from the Evil Empire

Bertrand Le Roy's blog

News


Bertrand Le Roy

BoudinFatal's Gamercard

Tales from the Evil Empire - Blogged

Blogs I read

My other stuff

Archives

Orchard list customization: first item template

I got this question more than once: "how can you use a different template for the first blog post?" The scenario is illustrated by this example:How the New-York Times displays headlines

If you look at the default rendering for the list of posts in a blog, you'll see this:

<ul class="blog-posts content-items">
  <li class="first">
    <article class="content-item blog-post">
      <header>...

As you can see, there is a "first" class added to the first list item tag, which allows for some styling customization out of the box. Unfortunately CSS is only half of the story, and it is insufficient if you are aiming for something like the NYT list pictured above.

What we really want is an alternate template for the first item, so that we can profoundly modify the layout, and display more things, like a large photo or more text.

Luckily, Orchard has just the right feature, aptly called Shape Alternates. If you enable Shape Tracing (which can be found in the Designer Tools module) and select a post in the list, you'll see something like this:Shape Tracing the blog post summary

As you can see, we already have some possible alternates in here. All we need to do is add a new one for the first item in the list and we will then be able to override it in our theme.

In order to do that, we need to somehow get into the list rendering and modify the item summary shapes from there.

The default rendering for lists is defined in code, not in a template (Orchard considers methods with a Shape attribute and cshtml templates to be equivalent ways of rendering a shape). Here is the code for it:

[Shape]
public void List(
    dynamic Display,
    TextWriter Output,
    IEnumerable<dynamic> Items,
    string Tag,
    string Id,
    IEnumerable<string> Classes,
    IDictionary<string, string> Attributes,
    IEnumerable<string> ItemClasses,
    IDictionary<string, string> ItemAttributes) {

    if (Items == null)
        return;

    var count = Items.Count();
    if (count < 1)
        return;

    var listTagName = string.IsNullOrEmpty(Tag) ? "ul" : Tag;
    const string itemTagName = "li";

    var listTag =
GetTagBuilder(listTagName, Id, Classes, Attributes); Output.Write(listTag.ToString(TagRenderMode.StartTag)); var index = 0; foreach (var item in Items) { var itemTag =
GetTagBuilder(itemTagName, null,
ItemClasses, ItemAttributes); if (index == 0) itemTag.AddCssClass("first"); if (index == count - 1) itemTag.AddCssClass("last"); Output.Write(itemTag.ToString(TagRenderMode.StartTag)); Output.Write(Display(item)); Output.Write(itemTag.ToString(TagRenderMode.EndTag)); ++index; } Output.Write(listTag.ToString(TagRenderMode.EndTag)); }

The logic of that method can be overridden by a new template in our theme. As Shape Tracing can show, we can override the list rendering for a blog by creating a Parts.Blogs.BlogPost.List.cshtml template in our theme's Views folder:

@using Orchard.DisplayManagement.Shapes;
@{
    var list = Model.ContentItems;
    var items = list.Items;
    var count = items.Count;
    var listTag = Tag(list, "ul");
    listTag.AddCssClass("content-items");
    listTag.AddCssClass("blog-posts");
    var index = 0;
}
@listTag.StartElement
    @foreach (var item in items) {
        var itemTag = Tag(item, "li");
        if (index == 0) {
            itemTag.AddCssClass("first");
        }
        else if (index == count - 1) {
            itemTag.AddCssClass("last");
        }
        @itemTag.StartElement
        @Display(item)
        @itemTag.EndElement
        ++index;
    }
@listTag.EndElement

Like the shape method above, this template is rendering the UL and LI tags with the appropriate classes and then loops over the items in the list, delegating the rendering of each to the proper template.

So far so good, we have effectively taken over the rendering of the list, but the actual HTML that this generates should be exactly identical to what we had before. The only difference is the implementation detail that it is our theme that did the rendering.

Alternates are a collection of strings that describe additional shape names for the current shape. That list of strings lives in the Metadata.Alternates property of any shape:Alternates in the debugger

All we need to do is add to this list and the system will be able to see a specialized template for our first item. It looks like all you would have to do is to add to the collection from within that "if (index == 0)" block above.

Not so fast.

That will not work because at the time this code is running, the rest of the system has not had a chance to chime in and propose its own alternates (the ones we saw in the first screenshot). The list at this point is empty. The problem is that alternate templates are being matched starting from the end of the list of alternates. If we added our alternate from here, it would be first in the list, so it would have the lowest priority.

The way out of this is fairly simple, we just need to respect the lifecycle and add our own event handler to the right point (OnDisplaying):

ShapeMetadata metadata = item.Metadata;
string alternate = metadata.Type + "_" +
        metadata.DisplayType + "__" +
        item.ContentItem.ContentType +
        "_First";
metadata.OnDisplaying(ctx => {
    metadata.Alternates.Add(alternate);
});

This will work because the theme code will be run last: the theme is considered more specialized than any module.

Here is the complete code for Parts.Blogs.BlogPost.List.cshtml:

@using Orchard.DisplayManagement.Shapes;
@{
    var list = Model.ContentItems;
    var items = list.Items;
    var count = items.Count;
    var listTag = Tag(list, "ul");
    listTag.AddCssClass("content-items");
    listTag.AddCssClass("blog-posts");
    var index = 0;
}
@listTag.StartElement
    @foreach (var item in items) {
        var itemTag = Tag(item, "li");
        if (index == 0) {
            ShapeMetadata metadata = item.Metadata;
            string alternate = metadata.Type + "_" +
                    metadata.DisplayType + "__" +
                    item.ContentItem.ContentType +
                    "_First";
            metadata.OnDisplaying(ctx => {
                metadata.Alternates.Add(alternate);
            });
            itemTag.AddCssClass("first");
        }
        else if (index == count - 1) {
            itemTag.AddCssClass("last");
        }
        @itemTag.StartElement
        @Display(item)
        @itemTag.EndElement
        ++index;
    }
@listTag.EndElement

The list of alternates from Shape Tracing now contains a new item:The new alternate template

We can now create a Content-BlogPost.First.Summary.cshtml template:

@using Orchard.Utility.Extensions;
@{
  var contentTypeClassName =
((string)Model.ContentItem.ContentType).HtmlClassify(); var item = Model.ContentItem; } <article class="content-item @contentTypeClassName"> <header> <h1>
<
a href="@item.RoutePart.Path">@item.RoutePart.Title</a>
</
h1> </header> @item.BodyPart.Text </article>

The results can be seen in the following screenshot:image

Comments

ejsa13 said:

Hi Bertrand,

I am new to Orchard and ASP.NET MVC and still learning it. I tried to use the code above (the first part of Parts.Blogs.BlogPost.List.cshtml)to override the rending of my generic list (created a ContentType named Project) but I am encountering an error with this line

var listTag = Tag(list, "ul");

The error thrown was:

The type arguments for method 'System.Web.Mvc.TagBuilder.MergeAttributes<TKey,TValue>(System.Collections.Generic.IDictionary<TKey,TValue>, bool)' cannot be inferred from the usage. Try specifying the type arguments explicitly.

I assume that the this error was not thrown when you used it because you are using Blog part and I am using a generic one.

What should I do?

# May 27, 2011 4:22 AM

Bertrand Le Roy said:

Attach a debugger and look at that list variable. I wouldn't be surprised if it was null.

# May 27, 2011 1:41 PM

Skywalker said:

Interesting! You are setting up the OnDisplaying event from within the template of the "current" shape (the List) for the first item. So as soon as you call @Display(item), that event gets fired, registering an alternate, after which Orchard will pick the correct template to render the list item. That's really cool.

I'm wondering, when we are inside Parts.Blogs.BlogPost.List.cshtml and inside the foreach item in items loop, "item" is a shape and not a ContentItem? Or is it a ContentItem, for which we call @Display(item), after which the ContentPartDriver for that ContentItem kicks in, which in turn creates shapes?

Which would make it obvious that before @Display(item) gets called, no child shapes exist for "item", but after @Display() returns, I could access the generated child shapes of the current shape (Model), is that correct?

# August 30, 2011 7:40 PM

Bertrand Le Roy said:

@Skywalker: You only ever call Display on shapes, so yes, item is a shape there. Those shapes were built by the controller or driver that created the list shape.

# August 30, 2011 7:59 PM

Skywalker said:

Ok! So if I have a List of ContentItems of type Page, and I'm currently in List.cshtml inside a foreach loop (foreach item in Model.ContentItems), each item is a shape that is just the result of merging all the shapes that each ContentPart of the Page created.

In other words, the entire Shape hierarchy will have been created before rendering kicks in, exactly like it's supposed to be with MVC: Build the entire viewmodel (the final shape) from the controller, then pass that shape to the view engine. Based on the shape's metadata (alternates), the correct (partial) view will be selected and applied, starting with Document, the root, continuing with Zones, etc. Each view will render some HTML and invoke @Display() for certain properties (child shapes) of the current branch. Each partial view acts as a skin for the shape branches.

It's a bit like the leaves on the branches of a tree, together creating the bigger picture. That may very well be the reason for the name "Orchard". Am I the first who found out? :)

You know there's a saying: I can't see the forest because of all those trees. But now I see!

Thanks.

# August 30, 2011 8:51 PM

Bertrand Le Roy said:

Each item is a shape that typically came from calls to BuildDisplay by the driver or controller (see BlogController.List for example). When Display is called on this one, a template to render it will be found, and that template will typically render a bunch of zones. Those zones will typically already have shapes in them, which are the shapes for the parts, as created by the relevant drivers and dispatched to each zone according to placement.

So yes, you got that pretty much right: a tree of shapes is built that is the analog of a view model, except it's a tree and it's dynamic, and then we find the templates to render each, recursively. The name Orchard was found way before we built that engine, but it's a neat coincidence.

# August 30, 2011 9:07 PM

orchaduser said:

How can I sort items in foreach loop by created date

@listTag.StartElement

   @foreach (var item in items) {

       var itemTag = Tag(item, "li");

       if (index == 0) {

           ShapeMetadata metadata = item.Metadata;

           string alternate = metadata.Type + "_" +

                   metadata.DisplayType + "__" +

                   item.ContentItem.ContentType +

                   "_First";

           metadata.OnDisplaying(ctx => {

               metadata.Alternates.Add(alternate);

           });

           itemTag.AddCssClass("first");

       }

       else if (index == count - 1) {

           itemTag.AddCssClass("last");

       }

       @itemTag.StartElement

       @Display(item)

       @itemTag.EndElement

       ++index;

   }

@listTag.EndElement

# January 3, 2012 11:43 AM

Bertrand Le Roy said:

With Linq? Or from whatever controller or driver or service class does the actual querying. I see no good reason to do something like that in the view.

# January 6, 2012 4:14 AM

Bertrand Le Roy said:

@Alex: it means that the driver did not properly populate that list or whatever it is. Fix it ;) What do you see under Model.Metadata?

# May 1, 2012 10:08 PM

Felipe Machado said:

Hi Bertranda, I'm having the same problem of Alexa and ejsa13. For a custom content type, one created entirely inside Orchard dashboard, not one written from scratch in code, this code fails. But why?

Since it is a list created inside Orchard admin interface, it's items will be something that is contained. I'm using this alternate:

~/Themes/XPI/Views/Parts.Container.Contained-List-url-historia.cshtml

The code for it, when I hit CREATE in shape tracing ends up like this:

@Display(Model.List)

@Display(Model.Pager)

So, of course Model.ContentItem won't get me to the list, it will be in Model.List. I'm trying to figure out now how to adapt your code to work in this context. Any ideas?

# September 15, 2012 8:01 AM

Felipe Machado said:

Bertrand,

Sorry for being so obtuse! It was really very easy to adapt your code for taking over the list rendering of a custom content type using a custom List created inside the admin area. That's the alternate I've used:

@using Orchard.DisplayManagement.Shapes;

@{

   var list = Model.List;

   var items = list.Items;

   var count = items.Count;

   var listTag = Tag(list, "ul");

   listTag.AddCssClass("content-items");

   listTag.AddCssClass("blog-posts");

   var index = 0;

}

@listTag.StartElement

   @foreach (var item in items) {

       var itemTag = Tag(item, "li");

       if (index == 0) {

           itemTag.AddCssClass("first");

       }

       else if (index == count - 1) {

           itemTag.AddCssClass("last");

       }

       @itemTag.StartElement

       @Display(item)

       @itemTag.EndElement

       ++index;

   }

@listTag.EndElement

@Display(Model.Pager)

Did you notice the difference? :-)

(Thanks for all your posts, I'm very new to Orchard (1 week of study/development) and you've helped me out in more than 5 problems already, even if you don't know! Even in Stack Overflow it was your hints that set me straight!)

# September 15, 2012 8:06 AM