In this 3 part series, we will be reviewing how to create a framework for creating content, adding specific workflows to that content, then managing that content as 1 cohesive group. In other works, this provides an easy way for content authors to manage media and content, with workflow, and then publish all those items (only those items) at the same time, via workflow. It also allows for archiving and unarchiving projects if the same content items are done on regular basis.
The general workflow for a content author is:
- Create a project item, with automatically created workflow
- Create a content item
- Tag the content item with the project.
The “Project Tag” template.
This template represents a “project”. A project’s name is arbitrary, but the item represents a collection of related content items.
- The Approver field will hold the sitecore username that will be the approver for the content of the projects.
- The Editors field will hold a “|” separated list of sitecore usernames that will be editors on this project.
- Each project has it’s own workflow. The guid of the workflow will be added here.
- Project Name is just the name of the project.
- Project Description is a description for the project.
- Archived controls whether or not this is actively shown in the dashboard. (We will review the dashboard soon.)
The “Project Tag Folder” template.
This is a simple template that only has an insert option set on it’s standard values. The insert option is the “Project Tag” template.
That Standard Template
On the standard template, add 2 new fields in a new section. I added the Project Tag and Project Tag History fields. The source field of each contains the guid of the “Project Folder” in the content tree. Once you make the Projects folder (which is the “Project Tag Folder” template type), you can make the standard template change.
Template Workflow
A pre-requisite is that you create a workflow that will be the template workflow for all project workflows. If you look closely at the save handler code coming up, you’ll see that we are looking for a specific workflow to duplicate.
In this case, I created an “Advanced Workflow”, which is used as the template workflow. This workflow is copied and the references are changed to create a specific workflow for each project item when the project item is saved.
Project Tag Item Saved Handlers
There are 2 item saved event handlers for the project tag template. The first is the ProjectTagSavedHandler.
The first: ProjectTagSavedHandler
This item saved handler is doing a few things:
- Targets only the Project Tag template
- Makes a new workflow by copying the designated template workflow and changing it’s paths. This workflow is named the same as the project name field on the “Project Tag” item. The project name field will by default be the item name because we have added $name to the standard values of the Project Tag template.
- Updates the security on the workflow to associate the approver with the projects workflow. This means that this workflow and item in this workflow will show up in the approvers Workbox. We are preserving the out of the box Sitecore functionality.
<handler type="SampleSite.Events.ProjectTagSavedHandler, SampleSite" method="OnItemSaved"></handler>
using System;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Events;
using Sitecore.Security.AccessControl;
using Sitecore.SecurityModel;
namespace SampleSite.Events
{
public class ProjectTagSavedHandler
{
public void OnItemSaved(object sender, EventArgs args)
{
// Extract the item from the event Arguments
Item savedItem = Event.ExtractParameter(args, 0) as Item;
// Allow only non null items and allow only items from the master database
if (savedItem != null && savedItem.Database.Name.ToLower() == "master")
{
// If the item is Project Tag Template
if (savedItem.TemplateID == ID.Parse("{C031C0E6-04BD-47C9-B206-C1F4C539DDCF}"))
{
Database database = savedItem.Database;
using (new SecurityDisabler())
{
string projectName = savedItem.Fields["Project Name"].ToString();
string approver = savedItem.Fields["Approver"].ToString();
string newWorkflowName = projectName;
Item newWorkflow = MakeNewWorkflow(database, newWorkflowName);
if (newWorkflow != null)
{
UpdateWorkflow(database, newWorkflowName, approver);
savedItem.Editing.BeginEdit();
savedItem["Workflow"] = newWorkflow.ID.ToString();
savedItem.Editing.EndEdit();
}
else
{
Sitecore.Diagnostics.Log.Info("Advanced Workflow Item was not found.", this);
}
}
}
}
}
private Item MakeNewWorkflow(Database database, string newWorkflowName)
{
string newWorkflowParentPath = "/sitecore/system/Workflows/";
string newWorkflowPath = string.Format("/sitecore/system/Workflows/{0}", newWorkflowName);
Item newWorkflowParentItem = database.GetItem(newWorkflowParentPath);
Item advancedWorkflow = database.GetItem(new ID("{7A6C5A24-F98C-4605-8248-8D274A1FEF87}"));
if (advancedWorkflow != null)
{
Item newWorkflowItem = database.GetItem(newWorkflowPath);
if (newWorkflowItem == null)
{
newWorkflowItem = advancedWorkflow.Duplicate(newWorkflowName);
newWorkflowItem.MoveTo(newWorkflowParentItem);
}
return newWorkflowItem;
}
else
{
return null;
}
}
private void UpdateWorkflow(Database database, string workflowName, string approver)
{
string workflowPath = string.Format("/sitecore/system/Workflows/{0}", workflowName);
string draftStatePath = string.Format("/sitecore/system/Workflows/{0}/Draft", workflowName);
string draftStateSubmitPath = string.Format("/sitecore/system/Workflows/{0}/Draft/Submit", workflowName);
string draftStateOnSavePath = string.Format("/sitecore/system/Workflows/{0}/Draft/__OnSave", workflowName);
string draftStateAutoSubmitPath = string.Format("/sitecore/system/Workflows/{0}/Draft/__OnSave/Auto Submit Action", workflowName);
string awaitingStatePath = string.Format("/sitecore/system/Workflows/{0}/Awaiting Approval", workflowName);
string awaitingStateApprovePath = string.Format("/sitecore/system/Workflows/{0}/Awaiting Approval/Approve", workflowName);
string awaitingStateApproveValidationPath = string.Format("/sitecore/system/Workflows/{0}/Awaiting Approval/Approve/Validation Action", workflowName);
string awaitingStateRejectPath = string.Format("/sitecore/system/Workflows/{0}/Awaiting Approval/Reject", workflowName);
string approvedStatePath = string.Format("/sitecore/system/Workflows/{0}/Approved", workflowName);
string approvedStatePublishedPath = string.Format("/sitecore/system/Workflows/{0}/Approved/Publish", workflowName);
string publishedStatePath = string.Format("/sitecore/system/Workflows/{0}/Published", workflowName);
string publishedStateAutoPublishPath = string.Format("/sitecore/system/Workflows/{0}/Published/Auto Publish", workflowName);
Item workflowItem = database.GetItem(workflowPath);
Item draftStateItem = database.GetItem(draftStatePath);
Item draftStateSubmitItem = database.GetItem(draftStateSubmitPath);
Item draftStateOnSaveItem = database.GetItem(draftStateOnSavePath);
Item draftStateAutoSubmitItem = database.GetItem(draftStateAutoSubmitPath);
Item awaitingStateItem = database.GetItem(awaitingStatePath);
Item awaitingStateApproveItem = database.GetItem(awaitingStateApprovePath);
Item awaitingStateApproveValidationItem = database.GetItem(awaitingStateApproveValidationPath);
Item awaitingStateRejectItem = database.GetItem(awaitingStateRejectPath);
Item approvedStateItem = database.GetItem(approvedStatePath);
Item approvedStatePublishedItem = database.GetItem(approvedStatePublishedPath);
Item publishedStateItem = database.GetItem(publishedStatePath);
Item publishedStateAutoPublishItem = database.GetItem(publishedStateAutoPublishPath);
workflowItem.Editing.BeginEdit();
workflowItem["Initial state"] = draftStateItem.ID.ToString();
workflowItem.Editing.EndEdit();
draftStateSubmitItem.Editing.BeginEdit();
draftStateSubmitItem["Next state"] = awaitingStateItem.ID.ToString();
draftStateSubmitItem.Editing.EndEdit();
draftStateOnSaveItem.Editing.BeginEdit();
draftStateOnSaveItem["Next state"] = draftStateItem.ID.ToString();
draftStateOnSaveItem.Editing.EndEdit();
draftStateAutoSubmitItem.Editing.BeginEdit();
draftStateAutoSubmitItem["Next state"] = awaitingStateItem.ID.ToString();
draftStateAutoSubmitItem.Editing.EndEdit();
awaitingStateApproveItem.Editing.BeginEdit();
awaitingStateApproveItem["Next state"] = approvedStateItem.ID.ToString();
awaitingStateApproveItem.Editing.EndEdit();
awaitingStateRejectItem.Editing.BeginEdit();
awaitingStateRejectItem["Next state"] = draftStateItem.ID.ToString();
awaitingStateRejectItem.Editing.EndEdit();
approvedStatePublishedItem.Editing.BeginEdit();
approvedStatePublishedItem["Next state"] = publishedStateItem.ID.ToString();
approvedStatePublishedItem.Editing.EndEdit();
//set security
awaitingStateItem.Editing.BeginEdit();
string awaitingStateItemSerializedSecurity = string.Format("au|{0}|pe|+workflowState:write|+workflowState:delete|pd|+workflowState:write|+workflowState:delete|", approver);
AccessRuleCollection awaitingStateItemAccessRuleCollection = AccessRuleCollection.FromString(awaitingStateItemSerializedSecurity);
awaitingStateItem.Security.SetAccessRules(awaitingStateItemAccessRuleCollection);
awaitingStateItem.Editing.EndEdit();
awaitingStateApproveItem.Editing.BeginEdit();
string awaitingStateApproveItemSerializedSecurity = string.Format("au|{0}|pe|+workflowCommand:execute|pd|+workflowCommand:execute|", approver);
AccessRuleCollection awaitingStateApproveItemAccessRuleCollection = AccessRuleCollection.FromString(awaitingStateApproveItemSerializedSecurity);
awaitingStateApproveItem.Security.SetAccessRules(awaitingStateApproveItemAccessRuleCollection);
awaitingStateApproveItem.Editing.EndEdit();
awaitingStateRejectItem.Editing.BeginEdit();
string awaitingStateRejectItemSerializedSecurity = string.Format("au|{0}|pe|+workflowCommand:execute|pd|+workflowCommand:execute|", approver);
AccessRuleCollection awaitingStateRejectItemAccessRuleCollection = AccessRuleCollection.FromString(awaitingStateRejectItemSerializedSecurity);
awaitingStateRejectItem.Security.SetAccessRules(awaitingStateRejectItemAccessRuleCollection);
awaitingStateRejectItem.Editing.EndEdit();
}
}
}
The second: ProjectTagArchiveOffHandler
This handler is examining the previous value of the “archived” flag to the new value of the “archived” flag. If a project moves from a archived state to unarchived state (checkbox field on the project) we search the index for all items that have the project the [project tag history] field. For each of those items, we set the active project to be this unarchived project. Authors need to manage projects closely. The assumption has been made that a single content item will only ever be in 1 active project at a time. There’s some good stuff in this code. Check out the FieldChangeList usage above. It took a little experimentation to get that just right. You’ve probably noticed that we are using search in this handler. I’ll cover those changes in a bit.
<handler type="SampleSite.Events.ProjectTagArchiveOffHandler, SampleSite" method="OnItemSaved"></handler>
using System;
using System.Linq;
using SampleSite.Search;
using Sitecore.ContentSearch;
using Sitecore.Data;
using Sitecore.Data.Events;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.Events;
using Sitecore.Links;
using Sitecore.Security.AccessControl;
using Sitecore.SecurityModel;
namespace SampleSite.Events
{
//This handler is examining the previous value of the "archived" flag to the new value of the "archived" flag.
//If a project moves from a archived state to unarchived state (checkbox field on the project)
//we search the index for all items that have the project the [project tag history] field.
//For each of those items, we set the active project to be this unarchived project.
//Authors need to manage projects closely. It's assumed that content items are only a part of 1 project at one time.
public class ProjectTagArchiveOffHandler
{
public void OnItemSaved(object sender, EventArgs args)
{
SitecoreEventArgs eventArgs = args as SitecoreEventArgs;
Sitecore.Diagnostics.Assert.IsNotNull(eventArgs, "eventArgs");
Item savedItem = eventArgs.Parameters[0] as Item;
Sitecore.Diagnostics.Assert.IsNotNull(savedItem, "item");
Database _database = savedItem.Database;
if (savedItem != null && _database.Name.ToLower() == "master")
{
// If the item is Project Tag Template
if (savedItem.TemplateID == ID.Parse("{C031C0E6-04BD-47C9-B206-C1F4C539DDCF}"))
{
if (eventArgs.Parameters.Length > 1)
{
var itemChanges = Event.ExtractParameter(args, 1) as ItemChanges;
if (itemChanges != null)
{
FieldChangeList fieldChangeList = itemChanges.FieldChanges;
foreach (FieldChange change in fieldChangeList)
{
if (change.FieldID == new ID("{D04D99DA-953A-49F2-87D1-8CB780D238A9}"))
{
if (change.OriginalValue == "1" && change.Value == "")
{
//project is being unarchived. activate it's tagged items.
this.ActivateProjectItems(savedItem);
return;
}
}
}
}
}
}
}
}
private void ActivateProjectItems(Item projectItem)
{
var _database = projectItem.Database;
using (new SecurityDisabler())
{
var index = ContentSearchManager.GetIndex("sitecore_master_index");
using (var context = index.CreateSearchContext())
{
string projectId = projectItem.ID.ToString();
var searchResults = context.GetQueryable<ProjectSearchResultItem>()
.Where(item => item.ProjecTagHistory.Contains(projectId))
.ToList();
foreach (var searchResult in searchResults)
{
Item taggedItem = _database.GetItem(searchResult.ItemId);
if (taggedItem.Paths.Path.Contains("sitecore/content/") || taggedItem.Paths.Path.Contains("sitecore/media library/"))
{
using (new EditContext(taggedItem))
{
ReferenceField projectTagField = taggedItem.Fields["Project Tag"];
projectTagField.Value = projectItem.ID.ToString();
}
}
}
}
}
}
}
}
Search Customizations
We need to be able to index the history of the projects that have been associated to a content item. This allows us to activate/deactivate projects and their content items. If a project is activated, all the content items that have that project ID in their history will be set to have that project as their active project and their workflow set to the correct workflow.
ProjectSearchResultItem
This class allows us to add a new field “ProjectTagHistory” to the result item.
using Sitecore.ContentSearch;
using Sitecore.ContentSearch.SearchTypes;
namespace SampleSite.Search
{
public class ProjectSearchResultItem : SearchResultItem
{
[IndexField("project_tag_history")]
public virtual string ProjecTagHistory { get; set; }
}
}
Search Configuration
I am adding the project tag history field to the search index. This is a computed index field.
<sitecore>
<contentSearch>
<indexConfigurations>
<defaultSolrIndexConfiguration type="Sitecore.ContentSearch.SolrProvider.SolrIndexConfiguration, Sitecore.ContentSearch.SolrProvider">
<fieldMap type="Sitecore.ContentSearch.SolrProvider.SolrFieldMap, Sitecore.ContentSearch.SolrProvider">
<fieldNames hint="raw:AddFieldByFieldName">
<field fieldName="project_tag_history" returnType="string" />
</fieldNames>
</fieldMap>
<documentOptions type="Sitecore.ContentSearch.SolrProvider.SolrDocumentBuilderOptions, Sitecore.ContentSearch.SolrProvider">
<fields hint="raw:AddComputedIndexField">
<field fieldName="project_tag_history">SampleSite.Search.ProjectTagHistoryField, SampleSite</field>
</fields>
</documentOptions>
</defaultSolrIndexConfiguration>
</indexConfigurations>
</contentSearch>
</sitecore>
Computed Index Field
This creates the content for the project_tag_history index field.
using Sitecore.ContentSearch;
using Sitecore.ContentSearch.ComputedFields;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
namespace SampleSite.Search
{
public class ProjectTagHistoryField : AbstractComputedIndexField
{
public string FieldName { get; set; }
public string ReturnType { get; set; }
public override object ComputeFieldValue(IIndexable indexable)
{
Item item = indexable as SitecoreIndexableItem;
MultilistField f = item.Fields["Project Tag History"];
if (f != null)
{
var multilist = f.GetItems();
if (multilist == null || multilist.Length == 0)
return null;
return string.Join(" ", multilist.Select(t => t.ID.ToString()));
}
return null;
}
}
}
Content Item Saved Handler
TaggedContentSavedHandler
This handler does 2 things.
- Associate the correct workflow to the item based on it’s project tag.
- Update the project tag history field.
This will run for all Sitecore items. If the item has a field name “Project Tag” on it, (from the standard template).
<handler type="SampleSite.Events.TaggedContentSavedHandler, SampleSite" method="OnItemSaved"></handler>
</event>
using System;
using System.Linq;
using Sitecore;
using Sitecore.Data;
using Sitecore.Data.Items;
using Sitecore.Events;
using Sitecore.SecurityModel;
using Sitecore.Workflows;
namespace SampleSite.Events
{
public class TaggedContentSavedHandler
{
public void OnItemSaved(object sender, EventArgs args)
{
// Extract the item from the event Arguments
Item savedItem = Event.ExtractParameter(args, 0) as Item;
// Allow only non null items and allow only items from the master database
if (savedItem != null && savedItem.Database.Name.ToLower() == "master")
{
if (savedItem.Fields.Where(x => x.Name == "Project Tag").Any())
{
Database database = savedItem.Database;
using (new SecurityDisabler())
{
//Assuming we are on a content item, get the selected tag for the content item
Sitecore.Data.Fields.ReferenceField projectTagField = savedItem.Fields["Project Tag"];
Sitecore.Data.Fields.MultilistField projectTagHistoryField = savedItem.Fields["Project Tag History"];
Sitecore.Data.Items.Item projectTag = projectTagField.TargetItem;
if (projectTag != null)
{
//Get the Tag item and find the workflow that is set for that item
Sitecore.Data.Fields.ReferenceField projectTagWorkflowField = projectTag.Fields["Workflow"];
Sitecore.Data.Items.Item projectTagWorkflow = projectTagWorkflowField.TargetItem;
IWorkflow workflow = database.WorkflowProvider.GetWorkflow(savedItem);
//Setup the saved item
if (workflow == null || workflow.WorkflowID != projectTagWorkflow.ID.ToString())
{
ChangeWorkflow(savedItem, projectTagWorkflow.ID);
ChangeDefaultWorkflow(savedItem, projectTagWorkflow.ID);
ChangeWorkflowState(savedItem, projectTagWorkflow.ID, "Draft");
}
if (!projectTagHistoryField.TargetIDs.Contains(projectTagField.TargetID))
{
using (new EditContext(savedItem))
{
projectTagHistoryField.Add(projectTagField.TargetID.ToString());
}
}
}
}
}
}
}
public static void ChangeWorkflow(Item item, ID workflowId)
{
using (new EditContext(item))
{
item[FieldIDs.Workflow] = workflowId.ToString();
}
}
public static void ChangeDefaultWorkflow(Item item, ID workflowId)
{
using (new EditContext(item))
{
item[FieldIDs.DefaultWorkflow] = workflowId.ToString();
}
}
public static WorkflowResult ChangeWorkflowState(Item item, ID workflowStateId)
{
using (new EditContext(item))
{
item[FieldIDs.WorkflowState] = workflowStateId.ToString();
}
return new WorkflowResult(true, "OK", workflowStateId);
}
public static WorkflowResult ChangeWorkflowState(Item item, ID workflowId, string workflowStateName)
{
//IWorkflow workflow = item.Database.WorkflowProvider.GetWorkflow(item);
IWorkflow workflow = item.Database.WorkflowProvider.GetWorkflow(workflowId.ToString());
if (workflow == null)
{
return new WorkflowResult(false, "No workflow assigned to item");
}
WorkflowState newState = workflow.GetStates()
.FirstOrDefault(state => state.DisplayName == workflowStateName);
if (newState == null)
{
return new WorkflowResult(false, "Cannot find workflow state " + workflowStateName);
}
return ChangeWorkflowState(item, ID.Parse(newState.StateID));
}
}
}
The advanced workflow
A note on the advanced workflow. In the reject command, we’ve create an email reject notification. This will notify the editors of the project that the approver has rejected the changes for this content item. The rest of the configuration for the workflow is standard.
The call flow is located below the image.
using SampleSite.ServiceLayer.Workflow;
using Sitecore.Data.Items;
using Sitecore.Workflows.Simple;
namespace SampleSite.Actions
{
public class RejectNotificationAction
{
public void Process(WorkflowPipelineArgs args)
{
WorkflowService workflowService = new WorkflowService();
//Get the item that is being reviewed and rejected.
Item item = args.DataItem;
if (item != null)
{
workflowService.ProcessRejection(item, args);
}
}
}
}
using SampleSite.Models.Workflow;
using SampleSite.ServiceLayer.Notification;
using Sitecore.Data.Items;
using Sitecore.Workflows.Simple;
using System.Collections.Generic;
namespace SampleSite.ServiceLayer.Workflow
{
public class WorkflowService
{
ProjectsService _projectService = new ProjectsService();
EmailService _emailService = new EmailService();
public void ProcessRejection(Item contentItem, WorkflowPipelineArgs args)
{
List<Project> activeProjects = _projectService.GetActiveProjectsForItem(contentItem);
foreach (var project in activeProjects)
{
string itemUri = contentItem.Uri.ToString();
string actionToTake = args.CommentFields["Action To Take"];
_projectService.NotifyEditorsAssetRejected(project, actionToTake, itemUri);
}
}
}
}
public List<Project> GetActiveProjectsForItem(Item item)
{
List<Project> projects = new List<Project>();
MultilistField multiselectField = item.Fields["Project Tag"];
if(multiselectField == null)
{
return new List<Project>();
}
Item[] items = multiselectField.GetItems();
if (items != null && items.Length > 0)
{
for (int i = 0; i < items.Length; i++)
{
Item projectItem = item.Database.GetItem(items[i].ID);
CheckboxField archived = projectItem.Fields["Archived"];
if (archived != null && !archived.Checked)
{
Project p = new Project(items[i]);
projects.Add(p);
}
}
}
return projects;
}
public void NotifyEditorsAssetRejected(Project project, string actionToTake, string itemUri)
{
string subject = string.Format("You have assets to review in {0}.", project.Name);
string body = string.Format("You have a rejected assets to review in {0} that require your attention.", project.Name);
string body1 = string.Format("<br />{0}<br /><a href='{1}'>{2}</a>", actionToTake, itemUri, itemUri);
string body2 = string.Format("Please log into your <a href='/sitecore/shell/CustomEditors/Workflow/ProjectDashboard.aspx'>Project Dashboard</a> to view the items in your queue.");
this.Notify(subject, string.Concat(body, body1, body2), project.ApproverUsers);
}
private void Notify(string subject, string message, List<User> users)
{
string[] to = users.Select(x => x.Profile.Email).ToArray();
string[] cc = Array.Empty<string>();
string[] bcc = Array.Empty<string>();
string from = Sitecore.Configuration.Settings.GetSetting("MailFromAddress");
_emailService.SendEmail(from, to, cc, bcc, subject, message);
}
using Sitecore.Diagnostics;
using System;
using System.Net.Mail;
using System.Threading.Tasks;
namespace SampleSite.ServiceLayer.Notification
{
public class EmailService
{
public void SendEmail(string from, string[] to, string[] cc, string[] bcc, string subject, string body)
{
try
{
MailMessage message = new MailMessage();
message.IsBodyHtml = true;
message.Subject = subject;
message.Body = body;
if (!string.IsNullOrWhiteSpace(from))
{
MailAddress mailAddress = new MailAddress(from);
message.From = mailAddress;
}
else
{
var error = $"Error occured sending mail:\n{"From string is empty"}";
Log.Error(error, this);
return;
}
if (to != null)
{
foreach (string _to in to)
{
MailAddress mailAddress = new MailAddress(_to);
message.To.Add(mailAddress);
}
}
else
{
var error = $"Error occured sending mail:\n{"To collection is empty"}";
Log.Error(error, this);
return;
}
if (cc != null)
{
foreach (string _cc in cc)
{
MailAddress mailAddress = new MailAddress(_cc);
message.CC.Add(mailAddress);
}
}
if (bcc != null)
{
foreach (string _bcc in bcc)
{
MailAddress mailAddress = new MailAddress(_bcc);
message.Bcc.Add(mailAddress);
}
}
SendMailMessage(message);
}
catch (Exception ex)
{
var error = $"Error occured sending mail:\n{ex.Message}\n{ex.StackTrace}";
Log.Error(error, ex, this);
}
}
private void SendMailMessage(MailMessage mailMessage)
{
Task.Run(
() =>
{
try
{
Sitecore.MainUtil.SendMail(mailMessage);
}
catch (Exception ex)
{
var error = string.Format("{0}:\n{1}\n{2}", "Error occured sending mail", ex.Message, ex.StackTrace);
Log.Error(error, ex, this);
}
}
);
}
}
}
The publish action configuration.
This concludes part one of the series. Part two will be taking a look and the “ProjectsDashboard” and some of the features of the dashboard and author/editor workflow, as well as, some custom item editors.