As I said in my earlier post, this series is about creating a real world site with Orchard CMS, I’m not covering using Orchard to manage the site, just the technical development parts. All of the code is available on codeplex here : http://orchardsamplesite.codeplex.com/ (the code for this post is in part-07 subdirectory)
Preamble
In this post I want to take a look at how we can use the orchard API to discover content. Continuing in the series with our fictitious product company, we want our product pages to be able to easily display a list of related content. We’ll find this content using the built in tags module that orchard provides and we’ll build a widget that allows us to find content with specific tags and display it as a list. Our spec is pretty simple;
- Allow administrator to drop a widget on a layer to find content that has specific tags.
- Allow the widget to be configured to require that for content to match it only needs to have one of the tags
- Allow the widget to be configured to require that for content to match it needs to have all of the tags
- Allow the widget to control whether or not the current page/content should be excluded if it were to match the tags
- Allow the widget to be configured to only retrieve N content items.
- Allow the widget to be configured to present a simple list of content item titles as links or to display the content item in summary form.
Doing this we can use the widget for a variety of purposes;
- Display a list of featured content on the homepage (content tagged “featured” and “home”)
- Display a list of related content within a product page (content tagged “product-X”)
- Display a list of FAQ articles and posts within a product page (content tagged “product-X” and “faq”)
- etc…
Setting up
Well, we’ve got to start with our old friends that make up the overall widget the record, part, handler, driver, migration, placement.info, module.txt and editor and display templates (phew! a lot of plumbing huh?). I’ve covered these topics in a fair amount of detail already and the docs are already quite extensive so for this post I’ll discuss mainly the driver in more detail as this is where the guts of the content discovery goes on;
First of all, let’s start with the record: in the SampleSiteModule project, create /models/RelatedContentWidgetRecord.cs;
using Orchard.ContentManagement.Records;
using Orchard.Environment.Extensions;
namespace SampleSiteModule.Models
{
[OrchardFeature("RelatedContent")]
public class RelatedContentWidgetRecord : ContentPartRecord
{
public virtual string TagList { get; set; }
public virtual int MaxItems { get; set; }
public virtual bool ExcludeCurrentItemIfMatching { get; set; }
public virtual bool MustHaveAllTags { get; set; }
public virtual bool ShowListOnly { get; set; }
}
}
Remember our module is exposing all of it’s different components as features that can be independently turned on/off, hence why all of our code is marked with the OrchardFeature attribute. The record describes all the settings we’ll use for our widget – the TagList property will be a comma separated list of tags to search for, MaxItems will control how many items we get at most, and the three bools control the widget behaviour – ExcludeCurrentItemIfMatching will strip out the current content item if it matches the rules (so a link to the current page doesn’t appear in the widget’s list), MustHaveAllTags controls whether all the specified tags must be present on the content items to be deemed a match or whether if can just contain at least one of them and ShowListOnly will allow the widget to render a simple list of links or a full blown summary view of the content items.
Next, we need to wrap the record up in a part - /models/RelatedContentWidgetPart.cs;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using Orchard.ContentManagement;
using Orchard.Environment.Extensions;
namespace SampleSiteModule.Models
{
[OrchardFeature("RelatedContent")]
public class RelatedContentWidgetPart : ContentPart<RelatedContentWidgetRecord>
{
[Required]
public string TagList
{
get { return Record.TagList; }
set { Record.TagList = value; }
}
[Required]
[DefaultValue(5)]
public int MaxItems
{
get { return Record.MaxItems; }
set { Record.MaxItems = value; }
}
[DefaultValue(true)]
public bool ExcludeCurrentItemIfMatching
{
get { return Record.ExcludeCurrentItemIfMatching; }
set { Record.ExcludeCurrentItemIfMatching = value; }
}
[DefaultValue(false)]
public bool MustHaveAllTags
{
get { return Record.MustHaveAllTags; }
set { Record.MustHaveAllTags = value; }
}
[DefaultValue(true)]
public bool ShowListOnly
{
get { return Record.ShowListOnly; }
set { Record.ShowListOnly = value; }
}
}
}
Nothing groundbreaking in there, so moving swiftly on, the handler – /handlers/RelatedContentWidgetRecordHandler.cs
using Orchard.ContentManagement.Handlers;
using Orchard.Data;
using Orchard.Environment.Extensions;
using SampleSiteModule.Models;
namespace SampleSiteModule.Handlers
{
[OrchardFeature("RelatedContent")]
public class RelatedContentWidgetRecordHandler : ContentHandler
{
public RelatedContentWidgetRecordHandler(IRepository<RelatedContentWidgetRecord> repository)
{
Filters.Add(StorageFilter.For(repository));
}
}
}
And the driver – for now, just create the following shell and we’ll build it up in a second – /drivers/RelatedContentWidgetDriver.cs
using Orchard.ContentManagement;
using Orchard.ContentManagement.Drivers;
using Orchard.Environment.Extensions;
using SampleSiteModule.Models;
namespace SampleSiteModule.Drivers
{
/// <summary>
/// Content part driver for the related content widget
/// </summary>
[OrchardFeature("RelatedContent")]
public class RelatedContentWidgetDriver : ContentPartDriver<RelatedContentWidgetPart>
{
protected override DriverResult Display(RelatedContentWidgetPart part, string displayType, dynamic shapeHelper)
{
var list = shapeHelper.List();
return ContentShape("Parts_RelatedContentWidget",
() => shapeHelper.Parts_RelatedContentWidget(
ShowListOnly : part.ShowListOnly,
ContentItems : list
));
}
protected override DriverResult Editor(RelatedContentWidgetPart part, dynamic shapeHelper)
{
return ContentShape("Parts_RelatedContentWidget_Edit",
() => shapeHelper.EditorTemplate(
TemplateName: "Parts/RelatedContentWidget",
Model: part,
Prefix: Prefix));
}
protected override DriverResult Editor(RelatedContentWidgetPart part, IUpdateModel updater, dynamic shapeHelper)
{
updater.TryUpdateModel(part, Prefix, null, null);
return Editor(part, shapeHelper);
}
}
}
Let’s setup a migration (note: in the code on codeplex, because I built this up in stages, the migration is a little different as it has a create and two updates to progressively add features) – create /Migration-RelatedContentWidget.cs;
using System.Data;
using Orchard.ContentManagement.MetaData;
using Orchard.Core.Contents.Extensions;
using Orchard.Data.Migration;
using Orchard.Environment.Extensions;
using SampleSiteModule.Models;
namespace SampleSiteModule
{
[OrchardFeature("RelatedContent")]
public class MigrationsRelatedContentWidget : DataMigrationImpl
{
public int Create()
{
// Define the persistence table as a content part record with
// my specific fields.
SchemaBuilder.CreateTable("RelatedContentWidgetRecord",
table => table
.ContentPartRecord()
.Column("TagList", DbType.String, a => a.Unlimited())
.Column("MaxItems", DbType.Int32)
.Column("ExcludeCurrentItemIfMatching", DbType.Boolean)
.Column("MustHaveAllTags", DbType.Boolean)
.Column("ShowListOnly", DbType.Boolean)
);
// Tell the content def manager that our widget is attachable
ContentDefinitionManager.AlterPartDefinition(typeof(RelatedContentWidgetPart).Name,
builder => builder.Attachable());
// Tell the content def manager that we have a content type
// the parts it contains and that it should be treated as a widget
ContentDefinitionManager.AlterTypeDefinition("RelatedContentWidget",
cfg => cfg
.WithPart("RelatedContentWidgetPart")
.WithPart("WidgetPart")
.WithPart("CommonPart")
.WithSetting("Stereotype", "Widget"));
return 1;
}
}
}
All of that should be pretty familiar, creates the record table, defines the widget part and the widget type with appropriate stereotype etc. See earlier posts in this series if you’re not sure what this is doing.
Next, we need to update placement.info so our parts will appear;
<Placement>
<Place Parts_TwitterWidget="Content:1"/>
<Place Parts_TwitterWidget_Edit="Content:7.5"/>
<Place Parts_RelatedContentWidget="Content:1"/>
<Place Parts_RelatedContentWidget_Edit="Content:7.5"/>
<!-- Synopsis -->
<!-- Note the summary view is the only display mode... -->
<Match DisplayType="Summary">
<Place Parts_Synopsis="Content:10"/>
</Match>
<Place Parts_Synopsis_Edit="Content:8"/>
</Placement>
We need to define the editor template next, in /Views/EditorTemplates/Parts/RelatedContentWidget.cshtml
@model SampleSiteModule.Models.RelatedContentWidgetPart
<fieldset>
<legend>Related Content</legend>
<div class="editor-label">@T("Tags to find (comma separated)"):</div>
<div class="editor-field">
@Html.TextBoxFor( m => m.TagList )
@Html.ValidationMessageFor( m => m.TagList )
</div>
<div class="editor-label">@T("Items must have all tags"):</div>
<div class="editor-field">
@Html.CheckBoxFor(m => m.MustHaveAllTags)
</div>
<div class="editor-label">@T("Exclude current content if matches?"):</div>
<div class="editor-field">
@Html.CheckBoxFor(m => m.ExcludeCurrentItemIfMatching)
</div>
<div class="editor-label">@T("Number of items to find"):</div>
<div class="editor-field">
@Html.TextBoxFor(m => m.MaxItems)
@Html.ValidationMessageFor(m => m.MaxItems)
</div>
<div class="editor-label">@T("Show as list only? (Setting this will show just a basic list, otherwise the full summary view will be rendered for each item)"):</div>
<div class="editor-field">
@Html.CheckBoxFor(m => m.ShowListOnly)
</div>
</fieldset>
and the display template in /Views/Parts/RelatedContentWidget.cshtml;
@using Orchard.ContentManagement;
@{
IEnumerable<object> latest = Model.ContentItems;
}
@if (latest == null || latest.Count() < 1) {
<p>@T("No related content.")</p>
}
else {
<ul class="content-items">
@foreach (dynamic item in latest)
{
string title = item.Title;
ContentItem contentItem = item.ContentItem;
<li class="content-item-summary">
@if( Model.ShowListOnly )
{
@Html.ItemDisplayLink(title, contentItem)
}
else
{
@Display(item)
}
</li>
}
</ul>
}
We could have just left the default widget and list templates to render everything for us, but we wanted control over the list formatting and whether or not to just show links or full summary content, so in this case, we created the above template. Note the condition on ShowListOnly – if it’s not going to just show a list, we simply call @Display(item) and this will take care of rendering out the content item in summary format (regardless of it’s type).
The last thing to do now is update module.txt to reflect our new feature;
Name: Product Types
Category: Sample Site
Description: Content types for product list and main product page
Dependencies: Contrib.Reviews, Mello.ImageGallery
AntiForgery: enabled
Author: Tony Johnson
Website: http://www.deepcode.co.uk
Version: 1.0
OrchardVersion: 1.1
Features:
TwitterWidget:
Name: Twitter Widget
Category: Sample Site
Description: Widget for latest tweets
Synopsis:
Name: Synopsis
Category: Sample Site
Description: Allows synopsis to be added to types
RelatedContent:
Name: Related content widget
Category: Sample Site
Description: Widget to find content based on tags
Where are we
That’s a lot of stuff to put in place to get the widget ready and so far, it won’t do anything, which we’ll fix in a second, but you should now be able to enable this feature through the orchard admin interface and add a related content widget to your pages. You should see the following configuration;
and the display on the page will presently just show the no related content message (note here I’m using the default theme);
Updating the driver to get some content
This was one of the more difficult parts of this post, it took me an age to work out how the query interface worked, which was quite a surprise, but once you understand it, it’s pretty clear. Again, as ever, thanks to randompete and bertrandleroy on the forums for helping me out.
Getting content with one of the tags – the basic query
Lets start by just getting a list of content that has one of the tags, is ordered correctly and returns just the number of entries we’re looking for. To begin, we’re going to need an IContentManager injected by autofac that we can use to query content, so give the driver a constructor that takes one of these and store it into a private readonly field;
private readonly IContentManager _cms;
public RelatedContentWidgetDriver(IContentManager cms)
{
_cms = cms;
}
We can now use this to query for content in our driver’s display method – change it as follows;
1: protected override DriverResult Display(RelatedContentWidgetPart part, string displayType, dynamic shapeHelper)
2: {
3: // Convert CSV tags to list
4: List<string> tags = new List<string>();
5: if (!String.IsNullOrWhiteSpace(part.TagList))
6: {
7: Array.ForEach(part.TagList.Split(','), t =>
8: {
9: if (!String.IsNullOrWhiteSpace(t))
10: {
11: t = t.Trim();
12: if (!tags.Contains(t))
13: tags.Add(t);
14: }
15: });
16: }
17:
18: // If we have no tags.....
19: if (tags.Count < 1)
20: {
21: return ContentShape("Parts_RelatedContentWidget",
22: () => shapeHelper.Parts_RelatedContentWidget(
23: ContentItems: shapeHelper.List()
24: ));
25: }
26:
27: IEnumerable<TagsPart> parts =
28: _cms.Query<TagsPart, TagsPartRecord>()
29: .Where(tpr => tpr.Tags.Any(t => tags.Contains(t.TagRecord.TagName)))
30: .Join<CommonPartRecord>()
31: .OrderByDescending(cpr => cpr.PublishedUtc)
32: .Slice(part.MaxItems);
33:
34: // Create a list and push our display content items in
35: var list = shapeHelper.List();
36: list.AddRange(parts.Select(p => _cms.BuildDisplay(p, "Summary")));
37:
38: return ContentShape("Parts_RelatedContentWidget",
39: () => shapeHelper.Parts_RelatedContentWidget(
40: ShowListOnly : part.ShowListOnly,
41: ContentItems : list
42: ));
43: }
Ok, so the first section (lines 4-16) are just concerned with taking a CSV list from the widget’s part and splitting it down into a unique list of tags that we can search for. This takes into account spaces, empty items and so on, so just gives us a more sane list of tags to search for than what a user might have keyed. Lines 18-25 just deal with the condition where we have no tags at all, in which case we’re just returning the same as we did in our earlier example – an empty list that will just render the “no related content” message.
The code gets a little more interesting at line 27-32. Line 28 first of all starts a query against the TagsPart, so this will query against the properties exposed from any content item that has the tags part attached to it, on line 29 we then tell the query engine to build a query that looks at the tags associated with the content to determine if the tag name is in our provided list, if it is, it will be included in the results, if not, it won’t.
Line 30 then joins the common part onto the query for the next part of the query (this will do an inner join to bring the common part properties in) and line 31 orders the results, in descending order, by the published date exposed from the common part record. Finally, line 32 actually executes the query, getting just the first N rows only (MaxItems part property).
On line 35/36 we build a list shape and add the items we’ve found to it, asking the IContentManager to build the display shape for the item, in summary display mode (not necessarily what will be rendered, this just sets the shapes up into the shape tree).
Finally, line 38, we return the part shape as normal, containing the list we’ve build and the flag to indicate how we want the results formatted.
Excluding the current page from the results
Next we’ll get the widget to exclude the current page if that is appearing in the links. Interestingly there doesn’t appear to be any reliable way to ask orchard what the ID is of the current content item being rendered (if there is one, you won’t have one in a themed custom controller action perhaps), so, with a little help from randompete, I decided to take the current request URL and manually match it to the routepart urls to get the ID of the content item being rendered (most navigable content has the routepart).
With the content id in hand, it’s then trivial to add an exclusion to the above query so that item doesn’t get included. To start with though, we’ll need a way to get access to the current work context, so add a dependency and field on the driver for an IWorkContextAccessor;
private readonly IContentManager _cms;
private readonly IWorkContextAccessor _work;
public RelatedContentWidgetDriver(IContentManager cms, IWorkContextAccessor work)
{
_cms = cms;
_work = work;
}
And then, add a method to your driver that will get the current id of the page/content being rendered for the current url;
/// <summary>
/// Helper that will attempt to work out the current content id from the url
/// of the request.
/// </summary>
/// <param name="defaultIfNotFound"></param>
/// <returns></returns>
private int TryGetCurrentContentId(int defaultIfNotFound)
{
string urlPath = _work.GetContext().HttpContext.Request.AppRelativeCurrentExecutionFilePath.Substring(2);
var routableHit = _cms
.Query<RoutePart, RoutePartRecord>(VersionOptions.Published)
.Where(r => r.Path == urlPath)
.Slice(1).FirstOrDefault();
if (routableHit != null) return routableHit.Id;
return defaultIfNotFound;
}
This isn’t doing anything particularly clever, it’s getting the URL of the current request (relative to the web app itself and stripping off the resultant ~/ from the beginning) and then performing another query against the RoutePart’s Path property to find the one that matches. If it is found, we return the id of the item that was found.
Back in our display method we can now use this to exclude the current item;
// Get current item
int currentItemId = -1;
if( part.ExcludeCurrentItemIfMatching )
currentItemId = TryGetCurrentContentId(-1);
IEnumerable<TagsPart> parts =
_cms.Query<TagsPart, TagsPartRecord>()
.Where(tpr => tpr.Tags.Any(t => tags.Contains(t.TagRecord.TagName)))
.Join<CommonPartRecord>()
.Where(cpr => cpr.Id != currentItemId)
.OrderByDescending(cpr => cpr.PublishedUtc)
.Slice(part.MaxItems);
Extend the query to honour our MustHaveAllTags part property.
And finally, we’ll change our query according to the MustHaveAllTags property on our widget. The display method has changed a bit, so here it is in it’s entirety with the changed area highlighted;
protected override DriverResult Display(RelatedContentWidgetPart part, string displayType, dynamic shapeHelper)
{
// Convert CSV tags to list
List<string> tags = new List<string>();
if (!String.IsNullOrWhiteSpace(part.TagList))
{
Array.ForEach(part.TagList.Split(','), t =>
{
if (!String.IsNullOrWhiteSpace(t))
{
t = t.Trim();
if (!tags.Contains(t))
tags.Add(t);
}
});
}
// If we have no tags.....
if (tags.Count < 1)
{
return ContentShape("Parts_RelatedContentWidget",
() => shapeHelper.Parts_RelatedContentWidget(
ContentItems: shapeHelper.List()
));
}
// See if we can find the current page/content id to filter it out
// from the related content if necessary.
int currentItemId = -1;
if( part.ExcludeCurrentItemIfMatching )
currentItemId = TryGetCurrentContentId(-1);
// Setup a query on the tags part
IContentQuery<TagsPart, TagsPartRecord> query = _cms.Query<TagsPart, TagsPartRecord>();
if (part.MustHaveAllTags)
{
// Add where conditions for every tag specified
foreach (string tag in tags)
{
string tag1 = tag; // Prevent access to modified closure
query.Where(tpr => tpr.Tags.Any(t => t.TagRecord.TagName == tag1));
}
}
else
{
// Add where condition for any tag specified
query.Where(tpr => tpr.Tags.Any(t => tags.Contains(t.TagRecord.TagName)));
}
// Finish the query (exclude current, do ordering and slice max items) and execute
IEnumerable<TagsPart> parts =
query.Join<CommonPartRecord>()
.Where(cpr => cpr.Id != currentItemId)
.OrderByDescending(cpr => cpr.PublishedUtc)
.Slice(part.MaxItems);
// Create a list and push our display content items in
var list = shapeHelper.List();
list.AddRange(parts.Select(p => _cms.BuildDisplay(p, "Summary")));
return ContentShape("Parts_RelatedContentWidget",
() => shapeHelper.Parts_RelatedContentWidget(
ShowListOnly : part.ShowListOnly,
ContentItems : list
));
}
As you can see, the only difference between “must have all tags” and “must have one of the tags” is the all tags adds a where clause for each tag directly – the rest of the query is unchanged.
Until next time
That’s it for this post – as ever, grab the code from codeplex: http://orchardsamplesite.codeplex.com under the part-07 folder (I’ve retired the final folder by the way as the posts are keeping up with the code now!). In the next post I’ll be looking at creating sub-navigation.