So you don't want to use placement.info?
In 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.
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.
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.
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:
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; }