So you don't want to use placement.info?

(c) Bertrand Le Roy 2010In Orchard, the UI gets composed from many independent parts. We wrote a lot of code to handle that fact without friction. It is easy to add a new part or remove an existing one without breaking anything. One ingredient in this is the placement.info file.

The role of placement is to dispatch the shapes that represent content types to local zones and to specify in what order they should appear. it separates the templates and their orchestration, implementing some healthy separation of concerns.

It is quite powerful and flexible, but it represents a sort of inversion of control that can be quite puzzling to designers: we are used, in other systems, to the layout pulling the different "includes" that constitute it. With placement, the "includes" are getting pushed into zones according to placement. This is similar to master pages in spirit, a concept we walked away from for similar reasons.

There is always a little pause in the learning of Orchard when people have to discover placement. In this post, I'm going to show how to do layout more explicitly, without placement. Whether it is a good idea or not, the possibility is there and will be easier and more familiar to some.

On my personal blog, the summary of a post is rendered by the regular ~/Core/Contents/Views/Content.cshtml template:

<article class="content-item @contentTypeClassName">
    <header>
        @Display(Model.Header)
        @if (Model.Meta != null) {
        <div class="metadata">
            @Display(Model.Meta)
        </div>
        }
    </header>
    @Display(Model.Content)
    @if(Model.Footer != null) {
    <footer>
        @Display(Model.Footer)
    </footer>
    }
</article>

This is not doing much except defining Header, Meta, Content and Footer local zones where placement can inject the shapes for the various parts forming the content item. We want to do without placement so these zones are not going to help us.

Here is the placement for summaries:

<Placement>
    <Match DisplayType="Summary">
        <Place Parts_RoutableTitle_Summary="Header:5"/>
        <Place Parts_Common_Body_Summary="Content:5"/>
        <Place Parts_Tags_ShowTags="Footer:0" />
        <Place Parts_Common_Metadata_Summary="Footer:1"/>
        <Place Parts_Comments_Count="Footer:2" />
    </Match>
</Placement>

We can see here where each shape is supposed to go: title in the header; text in content; tags, date and comment count in the footer. We'll now reproduce the exact same markup, but without placement.

The first thing to do is to override the Content.cshtml template in our theme with a more specialized alternate, Content-BlogPost.Summary.cshtml. This alternate will be used when rendering a blog post in summary form.

As always when writing templates, Shape Tracing is our best friend.Shape tracing

On the left side of the shape tracing tool, we can see the parts that entered the composition of the content summary rendering, confirming what placement showed.The shapes

On the right side, the Model tab enables us to drill into the shape used to represent Content, which is what the "Model" will represent from our template.Getting the title

For example, we can see that the title can be rendered with @Model.Title. We can drill further into the content item to find the body:Getting to the body text

Similarly we can find the tags, creation date and comments. Some of those are less trivial to render than the title, and we'll have to copy and adapt from the existing templates for each of the shapes.

The following template will render the markup that we want:

@using Orchard.ContentManagement
@{
    if (Model.Title != null) {
        Layout.Title = Model.Title;
    }
    var bodyHtml = Model.ContentItem.BodyPart.Text;
    var more = bodyHtml.IndexOf("<!--more-->");
    if (more != -1) {
        bodyHtml = bodyHtml.Substring(0, more);
    }
    else {
        var firstP = bodyHtml.IndexOf("<p>");
        var firstSlashP = bodyHtml.IndexOf("</p>");
        if (firstP >=0 && firstSlashP > firstP) {
            bodyHtml = bodyHtml.Substring(firstP,
firstSlashP + 4 - firstP); } } var body = new HtmlString(bodyHtml); } <article class="content-item blog-post"> <header><h1>
<a href="@Model.Path">@Model.Title</a>
</h1></header> <p>@body</p> <p>@Html.ItemDisplayLink(T("Read more...").ToString(),
(ContentItem)Model.ContentItem)</p> <footer> @{ var tagsHtml = new List<IHtmlString>(); foreach(var t in Model.ContentItem.TagsPart.CurrentTags) { if (tagsHtml.Any()) { tagsHtml.Add(new HtmlString(", ")); } tagsHtml.Add(Html.ActionLink(
(string)t.TagName,
"Search",
"Home",
new {
area = "Orchard.Tags",
tagName = (string)t.TagName
},
new { })); } } @if (tagsHtml.Any()) { <p class="tags"> <span>@T("Tags:")</span> @foreach(var htmlString in tagsHtml) { @htmlString } </p> } <div class="published">
@Model.ContentItem.CommonPart.CreatedUtc.ToString(
"MMM dd yyyy hh:mm tt")
</div> <span class="commentcount">
@T.Plural("1 Comment", "{0} Comments",
(int)Model.ContentItem.CommentsPart.Comments.Count)
</span> </footer> </article>

The problem with this approach is that although we did without placement and still got the same rendering, there is a lot of repetition here, notably from part templates. This is inconvenient because if we start applying that sort of method everywhere, we are going to repeat ourselves a lot, which will eventually become a maintenance nightmare.

What we really want to do is to render the same shapes as before, and to let their templates do their job. These shapes do still exist but are not obvious to find.

As part of its job preparing the shape tree, the controller action that was responsible for handling the current request will have made a number of calls into ContentManager.BuildDisplay. Some of those calls were for the summaries of blog posts. BuildDispay calls into the drivers for each of the parts forming the content item. This is how the shapes for each part get created. It will then use placement to dispatch those shapes into zones. Those zones have not yet been rendered, and in our case never will, but the shapes are there, ready to be used. Shape tracing is not showing them, but they are under one Model.NameOfTheZone or another. Of course, we don't know what zone they are in, or at what index, so to find them we have to scan the model for zones and the zones for shapes.

I wrote a little helper to make that easier and added it to my theme's project file:

using System;
using System.Collections.Generic;
using System.Linq;
using ClaySharp;
using Orchard.DisplayManagement;

namespace Util {
    public static class ShapeHelper {
        public static dynamic Find(IShape model, string name) {
            var zones = new Dictionary<string, object>();
            ((IClayBehaviorProvider)model).Behavior
.GetMembers(_nullFunc, model, zones); foreach (var key in zones.Keys
.Where(key => !key.StartsWith("_"))) {
var zone = zones[key] as IShape; if (zone == null ||
zone.Metadata.Type != "ContentZone") continue; foreach (IShape shape in ((dynamic)zone).Items) { if (shape.Metadata.Type == name) return shape; } } return null; } private static readonly Func<object> _nullFunc = () => null; } }

This is using some Clay wizardry to enumerate zones and then the shapes within them to find one with the specified name. Now with this helper, we can replace most of the code in the template and get something fairly clean and easy to understand:

@using Util
<article class="content-item blog-post">
    <header><h1><a href="@Model.Path">@Model.Title</a></h1></header>
    @Display(ShapeHelper.Find(Model, "Parts_Common_Body_Summary"))
    <footer>
    @Display(ShapeHelper.Find(Model, "Parts_Tags_ShowTags"))
    @Display(ShapeHelper.Find(Model, "Parts_Common_Metadata_Summary"))
    @Display(ShapeHelper.Find(Model, "Parts_Comments_Count"))
    </footer>
</article>

And this is it, we now have a very explicit layout for our blog post summaries, we are getting the same markup without placement, and the code is still nicely factored and maintainable. Of course, if you go that route, you will have to modify your templates every time you add a new part or field instead of relying on placement, but then again that's what you wanted…

Update: the ShapeHelper code was depending on Clay, a library that is no longer used by current versions of Orchard. Courtesy of Daniel Stolt, here is a version of its Find method that runs on 1.9, and should continue to work for the foreseeable future:

public static dynamic Find(dynamic shape, string shapeType, string shapeName = null) {
    if (shape.Metadata.Type == shapeType && (shapeName == null || shape.Name == shapeName)) {
        return shape;
    }
    foreach (var item in shape.Items) {
        var result = Find(item, shapeType, shapeName);
        if (result != null) return result;
    }
    return null;
}

8 Comments

  • Liked the idea but is there some other alternate to do so.

  • I was thinking about this today, along with your "Creating shapes on the fly" post, and something occurred to me.

    Using this example, I wanted to see if I could dispatch one of these shapes to one of the layout zones. For example, what if you wanted to put the Parts_Tags_ShowTags shape in the AsideSecond zone for some reason on blog posts? I'm not totally clear whether you can do that with this technique.

    But if you have the necessary data on your model, you can use the technique in that other post to do it. So if you take this same example and create a Content-BlogPost.cshtml template to override the default Content template, you can add this bit of Razor code to put the tags in the AsideSecond zone:

    @{
    var tagsPart = Model.ContentItem.TagsPart; //Just to make the next line easier
    WorkContext.Layout.AsideSecond.Add(New.Parts_Tags_ShowTags(ContentPart:tagsPart, Tags: tagsPart.CurrentTags));
    }

    This works because I can access the bits of data that the Tags_ShowTags shape expects. What do you think about that? Could I instead just do this? ...
    WorkContext.Layout.AsideSecond.Add(ShapeHelper.Find(...))

  • @Kevin: yes, you should try that second solution, in order to avoid having to create a new shape and reuse the existing one.

  • Hi Bertand. Thanks so much for providing this code. It's helping me through my learning curve as new Orchard adopter. I just wanted to point out that for whatever reason, the code as it's posted was giving me an YSOD when the method was called. The exception was...

    Unable to cast object of type 'IShapeProxyf18046ce9d044be78950e0623a808b00' to type 'ClaySharp.IClayBehaviorProvider'.

    I was able to get it working by changing the model argument to Shape instead of IShape and explicitly casting Model to Shape in my template (which is Content-BlogPost.cshtml if that's helpful)

    Anyway, I thought I should post that in case someone else has the same issue. Thanks for what you are doing with Orchard. I think it's going to be a great ASP.Net CMS.

  • Hi Bertrand, this is exactly what I'm looking for, so thanx, i'll see if I can get this to work.

    However, I would lvoe to solve this using placement.info, but I don't see how?

    In my case i use placement.info to determine the order and content of the searchresult summaries I'm displaying. For layout purposes, I want to add several shapes to a containing div. I don't think placement.info will let me do that?

    thanks, Rinze

  • @rinze: that is not what placement is for. Creating shapes on the fly from a template is super easy however: @Display.NameOfTheShape(SomeModelProperty: SomeValue, SomeOtherModelProperty: SomeOtherValue)

  • Hi,

    I'm having the same error as Joson.
    Unable to cast object of type 'IShapeProxyf18046ce9d044be78950e0623a808b00' to type 'ClaySharp.IClayBehaviorProvider'.

    But I'm not sure how to fix it. Any guidance will be appreciated.

    Thanks, john

  • I'm actually using you shapehelper to do this because I had trouble finding the shapes in my template model, so

    @Display(ShapeHelper.Find((Shape)Model.searchResult, "Content", "Parts_Title_Summary")

    using @Display.Parts_Title_Summary(Model.searchResult.????)

    would probably be preferred, but i'm unable to fill in the blanks.

    btw: I overloaded the function to only look in a specific zone .

Comments have been disabled for this content.