Sitecore Content Projects Part 2: Editor and Approver Content Management and Workflow

In the previous post, we went over the basic configurations in Sitecore that is necessary to be able to track content on a project basis. Let’s now dive into the user interface called the Project Dashboard, to see how authors and editors use this to manage content and projects.

The Project Dashboard

The project dashboard is the place where both editors and approvers go to manage projects. Let’s tour the different parts of the dashboard.

  1. This is the search panel. In the search panel, you can change the selection to be either all projects or just projects that you are an editor on, which is the “view my projects” selection. When projects are archived, meaning they are no longer active, they fall off the dashboard by default. You can however check the “Include Archived Projects” option to included archived projects as well. To further narrow down projects, you can search by project name or part of a name.
  2. This is a legend based on the workflow that is being used. This provides an at a glance view into the state of the project.
  3. This is the project row. In this row, a dot is shown indicating the status of the project. The status of the project uses the lowest state of all content items. For example, if there are 100 content items associated in a project and 99 of them are in approved state and 1 of them is still in the draft state, the project row would display a red dot indicating there are still items in this lowest state of draft. Next to the state is the Project Name. Clicking the name will take the user directly to the project in the content editor. To the right is an up/down arrow indicating that the row can be clicked and expanded.
  4. When a project row is expanded, there are a few commands that are available for the editors and approvers to use. Admittedly, I am not a UI designer and this UI could use a bit of an organizational up, however, it is usable.
    • The publish button becomes enabled when the project is green meaning when all the project content items are in the “approved” state and are ready to be published.
    • The archive button becomes enabled when the project is in a published state meaning all the content items are in the published workflow state.
    • The add to my projects button will add the current user as an editor to the project.
    • The remove from my projects button will remove the current user from the editors list.
    • Notify Approvers Assets Ready To Review will send an email to the approver that this project is ready to review.
    • Notify Editors Assets Ready to Publish means that all items have been approved and the publish can now take place, by clicking the active “Publish” button.
    • The drop down and Move All To Selected Workflow State work together. This gives ultimate control over the content items. By selecting any state, except the “final” state, and clicking the button, each of the project’s content items will be moved to that state in the workflow. Pretty powerful.
  5. This is the list of the content items in the project. Clicking on the text will take the editor directly to that content item in the content editor. Expanding these rows show you the workflow information associated with the content item.
  6. This is the continuation of the list of projects that were the result of the search.

The code for the dashboard could be considered slightly complicated. I’ve incorporated the use of the handlebars for the html templating. You’ll notice that project and item data are set in the html via data attributes. The javascript uses that for the api calls. See the dashboard javascript and service layers below.

<%@ page language="C#" Inherits="Sitecore.sitecore.admin.AdminPage" %>

<%@ import namespace="Sitecore.Data" %>
<%@ import namespace="Sitecore.Data.Items" %>
<%@ import namespace="Sitecore.Configuration" %>

<script runat="server">
    protected override void OnInit(EventArgs e)
    {
        this.CheckSecurity(false);
        base.OnInit(e);
    }   
</script>

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>Sitecore - Sitecore Projects</title>

    <script type="text/javascript" src="/scripts/local/js/handlebars-v4.7.6.js"></script>
    <script type="text/javascript" src="/scripts/local/js/project-dashboard-handlebars.js"></script>

    <link type="text/css" rel="stylesheet" href="/sitecore/shell/Themes/Standard/Default/GlobalHeader.css">
    <link href="/sitecore/images/favicon.ico" rel="shortcut icon">
    <link href="https://fonts.googleapis.com/css?family=Open+Sans:400,300italic,400italic,600italic,700italic,300,600,700,800" rel="stylesheet">
    <link href="/sitecore/shell/themes/standard/default/Default.css" rel="stylesheet">
    <link href="/sitecore/shell/controls/Lib/Flexie/flex.css" rel="stylesheet">
    <link href="/sitecore/shell/themes/standard/default/Content Manager.css" rel="stylesheet">
    <link href="/sitecore/shell/themes/standard/default/Ribbon.css" rel="stylesheet">
    <link href="/sitecore/shell/themes/standard/default/Workbox.css" rel="stylesheet">
    <link href="/sitecore/shell/themes/navigator.css" rel="stylesheet">

    <style>
        body {
            background-color: #f7f7f7;
            overflow: auto;
        }

        .rib {
            background-color: #f7f7f7;
            height: 120px;
            padding: 15px;
            width: 100%;
            padding-left: 0px;
        }

        .dc {
            margin-left: 0px;
            padding-left: 15px;
            padding-right: 15px;
        }

        .chunker {
            padding: 15px;
            border-right: 1px solid #e3e3e3;
            float: left;
            height: 105px;
        }

        .statusWindow {
            color: black;
            border-left: 5px solid #207da2;
            padding-left: 15px;
            font-weight: 600;
            font-size: 15px;
            padding: 15px;
            margin-bottom: 10px;
            background-color: #e0e0e0;
        }

        .stateicon {
            height: 15px;
            vertical-align: middle;
            margin-right: 10px;
        }

        #States .scBackground {
            margin-bottom: 2px;
        }

        .legend img {
            height: 15px;
        }

        .hide {
            display: none;
        }

        .sc-globalHeader{
            height:55px;
        }
    </style>
</head>
<body>
    <header class="sc-globalHeader">
        <div class="sc-globalHeader-content">
            <div class="col2">
                <div class="sc-globalHeader-startButton">
                    <a href="/sitecore/shell/sitecore/client/Applications/Launchpad" id="globalLogo" class="sc-global-logo"></a>
                </div>
            </div>
            <div class="col2">
                <div class="sc-globalHeader-loginInfo">
                    <ul class="sc-accountInformation">
                        <li><%= Sitecore.Context.User.Profile.UserName %><img id="globalHeaderUserPortrait" src="/temp/iconcache/office/32x32/default_user.png" border="0" alt="">
                        </li>
                    </ul>
                </div>
            </div>
        </div>
    </header>
    <div style="margin-left: 15px;">
        <div class="rib">
            <div class="chunker">
                <input type="radio" id="radioViewMine" name="viewType" value="mine" checked>
                <label for="radioViewMine">View My Projects</label><br>
                <input type="radio" id="radioViewAll" name="viewType" value="all">
                <label for="radioViewAll">View All Projects</label><br>
            </div>
            <div class="chunker">
                <input type="checkbox" id="checkIncludeArchived" name="archived">
                <label for="checkIncludeArchived">Include Archived Projects</label>
            </div>
            <div class="chunker">
                <label for="txtSearch">Search Projects</label>
                <input type="text" id="txtSearch" name="searchText" style="display: inline-block; margin-right: 3px; width: 300px;" />
            </div>
            <div class="chunker">
                <button id="btnSearch">Search</button>
            </div>
            <div class="chunker">
                <table class="legend">
                    <tr>
                        <td colspan="2">Draft State Legend</td>
                    </tr>
                    <tr>
                        <td>
                            <img src="/temp/iconcache/Other/32x32/bullet_ball_red.png" />
                        </td>
                        <td>Draft</td>
                    </tr>
                    <tr>
                        <td>
                            <img src="/temp/iconcache/Other/32x32/bullet_ball_yellow.png" />
                        </td>
                        <td>Awaiting Approval</td>
                    </tr>
                    <tr>
                        <td>
                            <img src="/temp/iconcache/Other/32x32/bullet_ball_green.png" />
                        </td>
                        <td>Approved</td>
                    </tr>
                    <tr>
                        <td>
                            <img src="/temp/iconcache/Applications/32x32/navigate_check.png" />
                        </td>
                        <td>Published</td>
                    </tr>
                    <tr>
                        <td>
                            <img src="/temp/iconcache/Applications/32x32/unknown.png" />
                        </td>
                        <td>Unknown</td>
                    </tr>
                </table>
            </div>
        </div>

        <div class="dc">
            <div id="statusWindow" class="statusWindow"></div>
        </div>
        <div class="dc">
            <div id="Grid2">
                <div id="States">
                </div>
            </div>
        </div>
    </div>

    <script id="project-template" type="text/x-handlebars-template">
        <div>
            {{#projects}}
            <table id="project-{{id}}" border="0" cellpadding="0" cellspacing="0" width="100%" class="closed">
                <tbody>
                    <tr class="scPaneTopRow">
                        <td width="100%" height="26" class="scGrayGradient">
                            <div class="scGrayGradientLightShadow scSpacerWrap">
                                <img src="/sitecore/images/blank.gif" style="width: 1px; height: 1px; vertical-align: middle">
                            </div>
                            <div style="position: relative;" onclick="toggleproject('{{id}}');">
                                <table border="0" cellpadding="0" cellspacing="0" class="scWorkBoxMainHeader" width="100%">
                                    <tbody>
                                        <tr>
                                            <td width="100%">
                                                <div class="scPaneHeader">
                                                    <img class="stateicon" src="{{stateSummaryIcon}}" /><a style="color: #fff; font-weight: 600" href="{{contentEditorUrl}}" target="_blank">{{name}}</a>
                                                </div>
                                            </td>
                                            <td align="right" nowrap="true" valign="top">
                                                <div>
                                                    <div style="display: inline;">
                                                    </div>
                                                    <img class="toggle-{{id}}" tabindex="0" hidefocus="true" src="/sitecore/shell/themes/standard/Images/accordion_down.png" style="margin: 4px 0 0 0; outline: none;" border="0" alt="Expand/Collapse" width="16px" height="16px">
                                                </div>
                                            </td>
                                        </tr>
                                    </tbody>
                                </table>
                            </div>
                        </td>
                    </tr>
                </tbody>
                <tbody class="tbody-{{id}} hide">
                    <tr>
                        <td style="background-color: #fff; padding: 15px" height="100%" valign="top">
                            <table class="scBackground" cellspacing="5" cellpadding="5" style="font-size: 12px">
                                <tbody>
                                    <tr class="scCollapsed">
                                        <td>Description: </td>
                                        <td>{{description}}</td>
                                    </tr>
                                    <tr class="scCollapsed">
                                        <td>Approver: </td>
                                        <td>{{approver}}</td>
                                    </tr>
                                    <tr class="scCollapsed">
                                        <td>Editors: </td>
                                        <td>{{editors}}</td>
                                    </tr>
                                    <tr class="scCollapsed">
                                        <td>Workflow: </td>
                                        <td><a href="{{workflowContentEditorUrl}}" target="_blank">{{workflowName}}</a></td>
                                    </tr>
                                    <tr class="scCollapsed">
                                        <td colspan="2">
                                            <button id="btnPublish-{{id}}" {{#unless isPublishable}} disabled {{/unless}} class="publish-button" data-projectid="{{id}}" data-username="<%= Sitecore.Context.User.Profile.UserName %>">Publish</button>
                                            <button id="btnArchive-{{id}}" {{#unless isArchiveable}} disabled {{/unless}} class="archive-button" data-projectid="{{id}}" data-username="<%= Sitecore.Context.User.Profile.UserName %>">Archive</button>
                                            <button id="btnAddToMyProjects-{{id}}" {{#if isUserEditor}} disabled {{/if}} class="add-to-project-button" data-projectid="{{{id}}}" data-username="<%= Sitecore.Context.User.Profile.UserName %>">Add To My Projects</button>
                                            <button id="btnRemoveFromMyProjects-{{id}}" {{#unless isUserEditor}} disabled {{/unless}} class="remove-from-project-button" data-projectid="{{{id}}}" data-username="<%= Sitecore.Context.User.Profile.UserName %>">Remove From My Projects</button>
                                            <button id="btnNotifyApprovers-{{id}}" class="notify-approvers-button" data-projectid="{{{id}}}" data-username="<%= Sitecore.Context.User.Profile.UserName %>">Notify Approvers Assets Ready To Review</button>
                                            <button id="btnNotifyEditors-{{id}}" class="notify-editors-button" data-projectid="{{{id}}}" data-username="<%= Sitecore.Context.User.Profile.UserName %>">Notify Editors Assets Ready To Publish</button>
                                            <select name="workflowStates-{{id}}" id="workflowStates-{{id}}">
                                              {{#projectWorkflow.projectWorkflowStates}}
                                                {{#unless finalState}}
                                                    <option value="{{stateID}}">{{displayName}}</option>
                                                {{/unless}}
                                              {{/projectWorkflow.projectWorkflowStates}}
                                            </select>
                                            <button id="btnMoveDraftState-{{id}}" class="workflow-state-button" data-workflowstateid="" data-projectid="{{{id}}}" data-username="<%= Sitecore.Context.User.Profile.UserName %>">Move All To Selected Workflow State</button>
                                            <a href="ProjectHeldAssets.aspx?projectid={{{id}}}" target="_blank">Review Missing Asset Report</a>
                                        </td>
                                    </tr>
                                </tbody>
                            </table>
                        </td>
                    </tr>
                </tbody>
                <tbody class="tbody-{{id}} hide">
                    <tr>
                        <td style="background-color: #fff; padding: 15px" height="100%" valign="top">{{#items}}
                            <table id="table-{{itemId}}" class="scBackground" onclick="toggleitem('{{itemId}}');">
                                <tbody>
                                    <tr class="scCollapsed">
                                        <td class="scSectionCenter">
                                            <img class="stateicon" src="{{workflowStateIcon}}" /><a style="color: #fff" href="{{itemContentEditorUrl}}" target="_blank">{{itemName}}<span style="font-weight: normal"> - {{itemId}} - {{itemPath}}</span></a></td>
                                        <td class="scSectionRight">
                                            <img id="toggle-{{itemId}}" src="/sitecore/shell/themes/standard/Images/accordion_down.png" width="16" height="16"></td>
                                    </tr>
                                    <tr id="row-{{itemId}}" class="hide">
                                        <td colspan="2">
                                            <div>
                                                Workflow Id: {{workflowId}}<br />
                                                Workflow Name: {{workflowName}}<br />
                                                Workflow State Id; {{workflowStateId}}<br />
                                                Workflow State Name: {{workflowStateName}}<br />
                                            </div>
                                            <div class="scNavigator" onclick="javascript:return scForm.postEvent(this,event)"></div>
                                        </td>
                                    </tr>
                                </tbody>
                            </table>
                            {{/items}}
                        </td>
                    </tr>
                </tbody>
            </table>
            {{/projects}}
        </div>
    </script>

    <script type="text/javascript">
        var sitecoreContextUserProfileUsername = "<%= Sitecore.Context.User.Profile.UserName.Replace(@"\",@"\\") %>";
        var projectDashboard = new ProjectDashboard(sitecoreContextUserProfileUsername);

        function toggleproject(id) {
            var table = document.getElementById("project-" + id);
            var tbodies = document.getElementsByClassName("tbody-" + id);
            var img = document.getElementsByClassName("toggle-" + id)[0];

            table.classList.toggle("open");
            table.classList.toggle("closed");

            for (var i = 0; i < tbodies.length; i++) {
                tbodies[i].classList.toggle("hide");
            }

            if (table.classList.contains("open")) {
                img.src = "/sitecore/shell/themes/standard/Images/accordion_up.png";
                return;
            }
            else {
                img.src = "/sitecore/shell/themes/standard/Images/accordion_down.png";
                return;
            }
        }

        function toggleitem(id) {
            var table = document.getElementById("table-" + id);
            var img = document.getElementById("toggle-" + id);
            var row = document.getElementById("row-" + id);

            table.classList.toggle("open");
            table.classList.toggle("closed");
            row.classList.toggle("hide");

            if (table.classList.contains("open")) {
                img.src = "/sitecore/shell/themes/standard/Images/accordion_up.png";
                return;
            }
            else {
                img.src = "/sitecore/shell/themes/standard/Images/accordion_down.png";
                return;
            }
        }
    </script>
</body>
</html>

Below it the javascript used for the dashboard.

function ProjectDashboard(currentUser) {
    this.SearchButton = document.getElementById("btnSearch");
    this.StatusWindow = document.getElementById("statusWindow");
    this.CurrentUser = currentUser;
    this.Init();
}

ProjectDashboard.prototype.Init = function () {
    var _this = this;

    this.SearchButton.addEventListener('click', function (e) {
        _this.Search();
        e.preventDefault();
    });

    this.Search();
}

ProjectDashboard.prototype.Search = function () {
    var viewType = document.querySelector('input[name="viewType"]:checked').value;
    var archived = document.querySelector('input[name="archived"]:checked') !== null;
    var searchText = document.querySelector('input[name="searchText"]').value;

    var searchCriteria = {
        viewType: viewType,
        archived: archived,
        searchText: searchText,
        userName: this.CurrentUser
    };

    this._listProjects(searchCriteria);

    var viewTypeText = "";
    if (viewType === "mine") {
        viewTypeText = "Mine";
    }
    else {
        viewTypeText = "All";
    }

    var archiveTextStatus = archived ? "selected" : "not selected"
    var searchTextStatus = searchText === "" ? "empty" : searchText;
    var entireStatus = `View type is ${viewTypeText}; Archive is ${archiveTextStatus}; Search text is ${searchTextStatus};`;
    this.StatusWindow.innerHTML = "<span>" + entireStatus + "</span>";
}

ProjectDashboard.prototype.Render = function () {
    var _this = this;

    this._listAllProjects();
    this._listMyProjects(this.CurrentUser);
}

ProjectDashboard.prototype.AddToMyProjectsButtonOnClick = function (project) {
    var _this = this;
    var data = project.dataset;
    var url = "/api/sitecore/projects/addtomylist";

    fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
    })
        .then(response => response.json())
        .then(data => {
            console.log('Success:', data);
            _this.Data = data;
            _this.Render();
            alert("You've been added to this project.")
        })
        .catch((error) => {
            console.log('Error:', error);
            alert("Error adding you to this project. " + error);
        });
}

ProjectDashboard.prototype.RemoveFromMyProjectsButtonOnClick = function (project) {
    var _this = this;
    var data = project.dataset;
    var url = "/api/sitecore/projects/removefrommylist";

    fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
    })
        .then(response => response.json())
        .then(data => {
            console.log('Success:', data);
            _this.Data = data;
            _this.Render();
            alert("You've been removed from this project.");
        })
        .catch((error) => {
            console.log('Error:', error);
            alert("Error removing you from this project. " + error);
        });

    
}

ProjectDashboard.prototype.ArchiveButtonOnClick = function (project) {
    var _this = this;
    var data = project.dataset;
    var url = "/api/sitecore/projects/archive";  

    fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
    })
        .then(response => response.json())
        .then(data => {
            console.log('Success:', data);
            _this.Data = data;
            _this.Render();
            alert("Archive Complete.");
        })
        .catch((error) => {
            console.log('Error:', error);
            alert("Error occurred during archive. " + error);
        });

    
}

ProjectDashboard.prototype.PublishButtonOnClick = function (project) {
    var _this = this;
    var data = project.dataset;
    var url = "/api/sitecore/projects/publish";

    fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
    })
        .then(response => response.json())
        .then(data => {
            console.log('Success:', data);
            _this.Data = data;
            _this.Render();
            alert("Publish Sumittied");
        })
        .catch((error) => {
            console.log('Error:', error);
            alert("Error during publish. " + error);
        });

    
}

ProjectDashboard.prototype.NotifyEditorsButtonOnClick = function (project) {
    var _this = this;
    var data = project.dataset;
    var url = "/api/sitecore/projects/notifyeditors";

    fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
    })
        .then(response => response.json())
        .then(data => {
            console.log('Success:', data);
            _this.Data = data;
            _this.Render();
            alert("Editor Notification Sent");
        })
        .catch((error) => {
            console.log('Error:', error);
            alert("Error sending editor notification " + error);
        });

    
}

ProjectDashboard.prototype.NotifyApproversButtonOnClick = function (project) {
    var _this = this;
    var data = project.dataset;
    var url = "/api/sitecore/projects/notifyapprovers";

    fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
    })
        .then(response => response.json())
        .then(data => {
            console.log('Success:', data);
            _this.Data = data;
            _this.Render();
            alert("Approver Notification Sent");
        })
        .catch((error) => {
            console.log('Error:', error);
            alert("Error sending approver notification. " + error);
        });

    
}

ProjectDashboard.prototype.WorkflowStateButtonOnClick = function (project) {
    var _this = this;
    var selectId = "workflowStates-" + project.dataset.projectid; 
    project.dataset.workflowstate = document.getElementById(selectId).value;
    var data = project.dataset;
    var url = "/api/sitecore/projects/changeworkflowstate";

    fetch(url, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data)
    })
        .then(response => response.json())
        .then(data => {
            console.log('Success:', data);
            _this.Data = data;
            _this.Render();
            alert("Worfkflow State has been updated.");
        })
        .catch((error) => {
            console.log('Error:', error);
            alert("Error updating worfkflow state. " + error);
        });

    
}

ProjectDashboard.prototype._updateSearchResults = function (data) {
    var results = JSON.parse(data);
    var source = document.getElementById("project-template").innerHTML;
    var template = Handlebars.compile(source);
    document.getElementById("States").innerHTML = template(results);
    this._refreshListeners();
}

ProjectDashboard.prototype._refreshListeners = function () {
    var _this = this;

    var publishButtons = document.getElementsByClassName("publish-button");
    for (var i = 0; i <= publishButtons.length - 1; i++) {
        publishButtons[i].addEventListener('click', function (e) {
            _this.PublishButtonOnClick(this);
            e.preventDefault();
        });
    }

    var archiveButtons = document.getElementsByClassName("archive-button");
    for (var i = 0; i <= archiveButtons.length - 1; i++) {
        archiveButtons[i].addEventListener('click', function (e) {
            _this.ArchiveButtonOnClick(this);
            e.preventDefault();
        });
    }

    var addToButtons = document.getElementsByClassName("add-to-project-button");
    for (var i = 0; i <= addToButtons.length - 1; i++) {
        addToButtons[i].addEventListener('click', function (e) {
            _this.AddToMyProjectsButtonOnClick(this);
            e.preventDefault();
        });
    }

    var removeButtons = document.getElementsByClassName("remove-from-project-button");
    for (var i = 0; i <= removeButtons.length - 1; i++) {
        removeButtons[i].addEventListener('click', function (e) {
            _this.RemoveFromMyProjectsButtonOnClick(this);
            e.preventDefault();
        });
    }

    var notifyEditorButtons = document.getElementsByClassName("notify-editors-button");
    for (var i = 0; i <= notifyEditorButtons.length - 1; i++) {
        notifyEditorButtons[i].addEventListener('click', function (e) {
            _this.NotifyEditorsButtonOnClick(this);
            e.preventDefault();
        });
    }

    var notifyApproversButtons = document.getElementsByClassName("notify-approvers-button");
    for (var i = 0; i <= notifyApproversButtons.length - 1; i++) {
        notifyApproversButtons[i].addEventListener('click', function (e) {
            _this.NotifyApproversButtonOnClick(this);
            e.preventDefault();
        });
    }

    var workflowStateButtons = document.getElementsByClassName("workflow-state-button");
    for (var i = 0; i <= workflowStateButtons.length - 1; i++) {
        workflowStateButtons[i].addEventListener('click', function (e) {
            _this.WorkflowStateButtonOnClick(this);
            e.preventDefault();
        });
    }

    
}

ProjectDashboard.prototype._listAllProjects = function (username) {
    var _this = this;
    var url = "/api/sitecore/projects/list?username=" + username;

    fetch(url)
        .then(response => response.text())
        .then(data => {
            console.log(data);
            _this._updateSearchResults(data);
        });
}

ProjectDashboard.prototype._listMyProjects = function (username) {
    var _this = this;
    var url = "/api/sitecore/projects/listByUser?username=" + username;

    fetch(url)
        .then(response => response.text())
        .then(data => {
            console.log(data);
            _this._updateSearchResults(data);
        });
}

ProjectDashboard.prototype._listProjects = function (searchCriteria) {
    var _this = this;

    var queryString = Object.keys(searchCriteria).map((key) => {
        return encodeURIComponent(key) + '=' + encodeURIComponent(searchCriteria[key])
    }).join('&');

    var url = "/api/sitecore/projects/search?" + queryString;

    fetch(url)
        .then(response => response.text())
        .then(data => {
            console.log(data);
            _this._updateSearchResults(data);
        });
}

The next set of code, is the core piece for the projects to work. It’s the project service. Most interesting is the Workflow manipulate logic.

using SampleSite.Extensions;
using SampleSite.Models.Workflow;
using SampleSite.ServiceLayer.Notification;
using Sitecore;
using Sitecore.Data;
using Sitecore.Data.Fields;
using Sitecore.Data.Items;
using Sitecore.Pipelines;
using Sitecore.Security.Accounts;
using Sitecore.SecurityModel;
using Sitecore.Workflows;
using Sitecore.Workflows.Simple;
using System;
using System.Collections.Generic;
using System.Linq;

namespace SampleSite.ServiceLayer.Workflow
{
    public class ProjectsService
    {
        Sitecore.Data.Database _database = null;

        const string ICON_RED = "/temp/iconcache/Applications/32x32/nav_plain_red.png";
        const string ICON_YEL = "/temp/iconcache/Applications/32x32/nav_plain_yellow.png";
        const string ICON_GRE = "/temp/iconcache/Applications/32x32/nav_plain_green.png";
        const string ICON_CHK = "/temp/iconcache/Applications/32x32/navigate_check.png";
        const string WORKFLOW_STATE_DRAFT = "Draft";
        const string WORKFLOW_STATE_AWAIT = "Awaiting Approval";
        const string WORKFLOW_STATE_APPRV = "Approved";
        const string WORKFLOW_STATE_PUBLI = "Published";
        private EmailService _emailService = null;
        public ProjectsService()
        {
            _database = Sitecore.Configuration.Factory.GetDatabase("master");
            _emailService = new EmailService();
        }

        public List<Project> Search(ProjectSearchRequest searchRequest)
        {
            var predicate = PredicateBuilder.True<Item>();

            if (!string.IsNullOrEmpty(searchRequest.ViewType) && searchRequest.ViewType == "mine")
            {
                predicate = predicate.And(x => x["Editors"].ToLower().Contains(searchRequest.UserName.ToLower()));
            }

            if (!string.IsNullOrEmpty(searchRequest.SearchText))
            {
                predicate = predicate.And(x => x["Project Name"].Contains(searchRequest.SearchText));
            }

            if (searchRequest.Archived)
            {
                predicate = predicate.And(x => x["Archived"] == string.Empty || x["Archived"] == "1");
            }
            else
            {
                predicate = predicate.And(x => x["Archived"] == string.Empty);
            }

            List<Project> projects = new List<Project>();
            ID projectFolderId = new ID("{4358F237-BDA7-44A6-A7AF-CB0FA6117D33}");

            using (new SecurityDisabler())
            {
                Item projectFolderItem = _database.GetItem(projectFolderId);

                if (projectFolderItem != null)
                {
                    var usersProjects = projectFolderItem.Children.Where(predicate.Compile()).ToList();
                    foreach (Item child in usersProjects)
                    {
                        if (child.TemplateID == new ID("{C031C0E6-04BD-47C9-B206-C1F4C539DDCF}"))
                        {
                            Project p = new Project(child, searchRequest.UserName);
                            projects.Add(p);
                        }
                    }
                }
            }

            return projects;
        }

        public List<Project> List(string username)
        {
            List<Project> projects = new List<Project>();
            ID projectFolderId = new ID("{4358F237-BDA7-44A6-A7AF-CB0FA6117D33}");

            using (new SecurityDisabler())
            {
                Item projectFolderItem = _database.GetItem(projectFolderId);

                if (projectFolderItem != null)
                {
                    foreach (Item child in projectFolderItem.Children)
                    {
                        if (child.TemplateID == new ID("{C031C0E6-04BD-47C9-B206-C1F4C539DDCF}"))
                        {
                            Project p = new Project(child, username);
                            projects.Add(p);
                        }
                    }
                }
            }

            return projects;
        }

        public List<Project> ListByUser(string username)
        {
            List<Project> projects = new List<Project>();
            ID projectFolderId = new ID("{4358F237-BDA7-44A6-A7AF-CB0FA6117D33}");

            using (new SecurityDisabler())
            {
                Item projectFolderItem = _database.GetItem(projectFolderId);

                if (projectFolderItem != null)
                {
                    var usersProjects = projectFolderItem.Children.Where(x => x["Editors"].Contains(username));
                    foreach (Item child in usersProjects)
                    {
                        if (child.TemplateID == new ID("{C031C0E6-04BD-47C9-B206-C1F4C539DDCF}"))
                        {
                            Project p = new Project(child, username);
                            projects.Add(p);
                        }
                    }
                }
            }

            return projects;
        }

        public Item AddToMyList(ProjectRequest projectRequest)
        {
            List<Project> projects = new List<Project>();
            ID projectId = new ID(projectRequest.ProjectId);
            Item projectItem = null;

            using (new SecurityDisabler())
            {
                projectItem = _database.GetItem(projectId);

                if (projectItem != null)
                {
                    if (!projectItem["Editors"].Contains(projectRequest.UserName))
                    {
                        var editors = projectItem["Editors"];
                        var editorsList = editors.Split('|').ToList();
                        editorsList.Add(projectRequest.UserName);

                        projectItem.Editing.BeginEdit();

                        try
                        {
                            projectItem["Editors"] = String.Join("|", editorsList.ToArray());
                            projectItem.Editing.EndEdit();
                        }
                        catch (Exception ex)
                        {
                            projectItem.Editing.CancelEdit();
                        }
                    }
                }
            }

            return projectItem;
        }

        public Item RemoveFromMyList(ProjectRequest projectRequest)
        {
            List<Project> projects = new List<Project>();
            ID projectId = new ID(projectRequest.ProjectId);
            Item projectItem = null;

            using (new SecurityDisabler())
            {
                projectItem = _database.GetItem(projectId);

                if (projectItem != null)
                {
                    if (projectItem["Editors"].Contains(projectRequest.UserName))
                    {
                        var editors = projectItem["Editors"];
                        var editorsList = editors.Split('|').ToList();
                        editorsList.Remove(projectRequest.UserName);

                        projectItem.Editing.BeginEdit();

                        try
                        {
                            projectItem["Editors"] = String.Join("|", editorsList.ToArray());
                            projectItem.Editing.EndEdit();
                        }
                        catch (Exception ex)
                        {
                            projectItem.Editing.CancelEdit();
                        }
                    }
                }
            }

            return projectItem;
        }

        public Item Archive(ProjectRequest projectRequest)
        {
            List<Project> projects = new List<Project>();
            ID projectId = new ID(projectRequest.ProjectId);
            Item projectItem = null;

            using (new SecurityDisabler())
            {
                projectItem = _database.GetItem(projectId);

                if (projectItem != null)
                {
                    projectItem.Editing.BeginEdit();

                    try
                    {
                        CheckboxField checkboxField = projectItem.Fields["Archived"];
                        checkboxField.Checked = true;
                        projectItem.Editing.EndEdit();
                    }
                    catch (Exception ex)
                    {
                        projectItem.Editing.CancelEdit();
                    }
                }
            }

            return projectItem;
        }

        public Item Publish(ProjectRequest projectRequest)
        {
            ID projectId = new ID(projectRequest.ProjectId);
            Item projectItem = _database.GetItem(projectId);
            Project project = new Project(projectItem, projectRequest.UserName);
            WorkflowState workflowState = this.GetFinalWorkflowState(project.ProjectWorkflow.WorkflowID);
            Item workflowStateItem = _database.GetItem(workflowState.StateID);

            if (project.IsPublishable)
            {
                foreach (var item in project.Items)
                {
                    if (item.WorkflowId == project.ProjectWorkflow.WorkflowID)
                    {
                        Item contentItem = _database.GetItem(item.ItemId);

                        using (new SecurityDisabler())
                        {
                            this.ChangeStateAndExecuteActions(contentItem, workflowStateItem.ID);
                        }
                    }
                }
            }

            return projectItem;
        }

        public Item ChangeWorkflowState(ProjectWorkflowRequest projectWorkflowRequest)
        {
            ID projectId = new ID(projectWorkflowRequest.ProjectId);
            Item projectItem = _database.GetItem(projectId);
            Project project = new Project(projectItem); ;
            Item workflowStateItem = _database.GetItem(projectWorkflowRequest.WorkflowState);

            foreach (var item in project.Items)
            {
                if (item.WorkflowId == project.ProjectWorkflow.WorkflowID)
                {
                    Item contentItem = _database.GetItem(item.ItemId);

                    using (new SecurityDisabler())
                    {
                        this.ChangeStateAndExecuteActions(contentItem, workflowStateItem.ID);
                    }
                }
            }

            return projectItem;
        }

        public void NotifyEditorsAssetsReadyToPublish(Project project)
        {
            string subject = string.Format("You have assets to review in {0}.", project.Name);
            string message = string.Format("Your contributed assets in {0} are approved for publish.", project.Name);
            this.Notify(subject, message, project.EditorUsers);
        }

        public void NotifyEditorsAssetsReadyToPublish(ProjectRequest projectRequest)
        {
            ID projectId = new ID(projectRequest.ProjectId);
            Item projectItem = _database.GetItem(projectId);
            Project project = new Project(projectItem, projectRequest.UserName);
            this.NotifyEditorsAssetsReadyToPublish(project);
        }

        public void NotifyApproversAssetsReadyForReview(Project project)
        {
            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 = "<br />" + project.Description + "<br />";
            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);
        }

        public void NotifyApproversAssetsReadyForReview(ProjectRequest projectRequest)
        {
            ID projectId = new ID(projectRequest.ProjectId);
            Item projectItem = _database.GetItem(projectId);
            Project project = new Project(projectItem, projectRequest.UserName);
            this.NotifyApproversAssetsReadyForReview(project);
        }

        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);
        }

        public void NotifyEditorsAssetRejected(ProjectRequest projectRequest, string actionToTake, string itemUri)
        {
            ID projectId = new ID(projectRequest.ProjectId);
            Item projectItem = _database.GetItem(projectId);
            Project project = new Project(projectItem, projectRequest.UserName);
            this.NotifyEditorsAssetRejected(project, actionToTake, itemUri);
        }

        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);
        }

        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 Item UpdateProject(string projectId, string approver, string editors)
        {
            ID projectID = new ID(projectId);
            Item projectItem = null;

            using (new SecurityDisabler())
            {
                projectItem = _database.GetItem(projectID);

                if (projectItem != null)
                {
                    var editorsList = editors.Split('|').ToList();
                    projectItem.Editing.BeginEdit();

                    try
                    {
                        projectItem["Approver"] = approver;
                        projectItem["Editors"] = String.Join("|", editorsList.ToArray());
                        projectItem.Editing.EndEdit();
                    }
                    catch (Exception ex)
                    {
                        projectItem.Editing.CancelEdit();
                    }
                }
            }
            return projectItem;
        }

        private IWorkflow GetWorkflow(string workflowId)
        {
            var workflow = _database.WorkflowProvider.GetWorkflow(workflowId);
            return workflow;
        }
        private WorkflowState GetFinalWorkflowState(string workflowId)
        {
            IWorkflow workflow = this.GetWorkflow(workflowId);
            WorkflowState[] workflowStates = workflow.GetStates();
            List<ProjectItem> items = new List<ProjectItem>();

            foreach (WorkflowState state in workflowStates)
            {
                if (state.FinalState)
                {
                    return state;
                }
            }

            return null;
        }

        private WorkflowResult ChangeStateAndExecuteActions(Item item, ID workflowStateId)
        {
            using (new EditContext(item))
            {
                item[FieldIDs.WorkflowState] = workflowStateId.ToString();
            }

            Item stateItem = item.Database.GetItem(workflowStateId);

            if (stateItem.HasChildren)
            {
                WorkflowPipelineArgs workflowPipelineArgs = new WorkflowPipelineArgs(item, null, null);

                Pipeline pipeline = Pipeline.Start(stateItem, workflowPipelineArgs);
            }

            return new WorkflowResult(true, "OK", workflowStateId);
        }

        public List<Item> Held(string projId)
        {
            List<Item> heldItems = new List<Item>();

            ID projectId = new ID(projId);
            Item projectItem = _database.GetItem(projectId);
            Project project = new Project(projectItem);

            if (project.Archived)
            {
                return new List<Item>();
            }

            List<Item> pages = new List<Item>();

            foreach (ProjectItem pi in project.Items)
            {
                if (pi.Item.HasLayout())
                {
                    pages.Add(pi.Item);
                }
            }

            foreach (var page in pages)
            {
                var renderings = page.Visualization.GetRenderings(Sitecore.Context.Device, false);

                foreach(var rendering in renderings)
                {
                    if(!string.IsNullOrWhiteSpace(rendering.Settings.DataSource))
                    {
                        ID datasourceId = ID.Parse(rendering.Settings.DataSource);
                        Item datasourceItem = page.Database.GetItem(datasourceId);
                        var datasourceProjects = this.GetActiveProjectsForItem(datasourceItem);
                        var inProject = datasourceProjects.Where(x => x.Id.ToLower().Equals(projId.ToLower())).Any();
                        if (!inProject)
                        {
                            heldItems.Add(datasourceItem);
                        }
                    }
                }
            }

            return heldItems;
        }
    }
}

Let’s discuss two custom item editors have been added to assist authors with managing projects. The first is a summary report of all the projects that have been defined. This custom item editor appears when you click on the projects folder. If you need a refresher on the custom item editors head over here to learn about them. The code for this custom item editor is listed below and is straight-forward.

<%@ page language="C#" %>

<%@ import namespace="Sitecore.Data" %>
<%@ import namespace="Sitecore.Data.Items" %>
<%@ import namespace="Sitecore.Configuration" %>

<script runat="server">
    protected string currentItemId = null;
    protected string databaseName = null;
    protected Database database = null;
    protected Item currentItem = null;

    protected string Process(string columnName, Item childItem)
    {   
        string fieldValue = GetFieldValue(columnName, childItem);
        if (string.IsNullOrEmpty(fieldValue)) return String.Empty;
        if (!fieldValue.Contains("|")) return GetItemName(fieldValue);
        return FormatData(fieldValue);
    }

    protected string GetFieldValue(string columnName, Item childItem)
    {
        if (childItem.Fields[columnName] == null || string.IsNullOrEmpty(childItem.Fields[columnName].Value)) return string.Empty;
        return childItem.Fields[columnName].Value;
    }

    protected string FormatData(string fieldValue)
    {
        string[] allSelectedItemId = fieldValue.Split('|');
        string finalResult = string.Empty;
        foreach (string itemId in allSelectedItemId)
        {
            finalResult = string.Concat(finalResult,"<br/>", GetItemName(itemId));
        }
        return finalResult.Remove(1,5);
    }

    protected string GetItemName(string id)
    {
        return this.database.GetItem(new ID(id)).Name;
    }
</script>

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>Summary Of Configured Projects</title>
   
    <script type="text/javascript" src="/scripts/jquery-3.3.1/js/jquery-3.3.1.slim.min.js"></script>
    <script type="text/javascript" src="/scripts/jquery.fancytree-2.30.2/dist/jquery.fancytree-all-deps.min.js"></script>
    <script type="text/javascript" src="/scripts/bootstrap-4.3.1/js/popper.min.js"></script>
    <script type="text/javascript" src="/scripts/bootstrap-4.3.1/js/bootstrap.min.js"></script>
    <script type="text/javascript" src="/scripts/tree.utilities/treenode.js"></script>
    <script type="text/javascript" src="/scripts/tree.utilities/treetraverser.js"></script>
    <script type="text/javascript" src="/scripts/tree.utilities/treelist.js"></script>
    <script type="text/javascript" src="/scripts/local/js/project-dashboard.js"></script>
    <link type="text/css" rel="stylesheet" href="/scripts/jquery.fancytree-2.30.2/dist/skin-win8/ui.fancytree.min.css">
    <link type="text/css" rel="stylesheet" href="/scripts/bootstrap-4.3.1/css/bootstrap.min.css">

</head>
<body>
    <div class="container-fluid" id="summaryOfProjects">
        <div class="row">
            <div class="col">
                <h2 style="text-align: left; margin-top: 20px; margin-left: 10px;">Summary Of Projects</h2>
                <table class="table table-striped table-bordered" style="text-align: left; width: auto;">
                    <thead>
                        <tr>
                            <th>Project Name</th>
                            <th>Description</th>
                            <th>Approver</th>
                            <th>Workflow</th>
                            <th>Editors</th>
                            <th>Archived</th>
                        </tr>
                    </thead>
                    <tbody>
                        <%
                        this.currentItemId = Request.QueryString["id"];
                        this.databaseName = Request.QueryString["database"];
                        this.database = Database.GetDatabase(this.databaseName);
                        this.currentItem = database.GetItem(new ID(this.currentItemId));

                        if (this.currentItem != null && this.currentItem.Children.Count > 0)
                        {
                            foreach (Item childItem in this.currentItem.Children)
                            { %>

                        <tr>
                            <td><% = GetFieldValue("Project Name", childItem) %></td>
                            <td><% = GetFieldValue("Description", childItem) %></td>
                            <td><% = GetFieldValue("Approver", childItem) %></td>
                            <td><% = GetFieldValue("Workflow", childItem) %></td>
                            <td><% = GetFieldValue("Editors", childItem) %></td>
                            <td><% = GetFieldValue("Archived", childItem) %></td>
                        </tr>

                        <%      }
                        } %>


                    </tbody>
                </table>
                <% = this.currentItemId %>
                <% = this.databaseName %>
                <% = this.currentItem.Name %>
                <% = this.currentItem.Children.Count %>
            </div>
        </div>
    </div>
</body>
</html>

The second custom item editor is to assist the author in choosing the editors and approvers for a project. The approvers matter from a workflow security perspective. The editors matter due to a notification perspective. When you click on the project, the edit project tab shows. On this tab, we list the general information about the project, but then we have two type ahead fields that allow for the approver and editors to be selected. In the future, we’ll turn this into a custom Sitecore control, but for now, the editor works well.

The following will list the code used for the custom item editor, javascript, and controller.

<%@ page language="C#" %>

<%@ import namespace="Sitecore.Data" %>
<%@ import namespace="Sitecore.Data.Items" %>
<%@ import namespace="Sitecore.Configuration" %>

<script runat="server">
    protected string currentItemId = null;
    protected string databaseName = null;
    protected Database database = null;

    protected string Process(string columnName, Item childItem)
    {   
        string fieldValue = GetFieldValue(columnName, childItem);
        if (string.IsNullOrEmpty(fieldValue)) return String.Empty;
        if (!fieldValue.Contains("|")) return GetItemName(fieldValue);
        return FormatData(fieldValue);
    }

    protected string GetFieldValue(string columnName, Item childItem)
    {
        if (childItem.Fields[columnName] == null || string.IsNullOrEmpty(childItem.Fields[columnName].Value)) return string.Empty;
        return childItem.Fields[columnName].Value;
    }

    protected string FormatData(string fieldValue)
    {
        string[] allSelectedItemId = fieldValue.Split('|');
        string finalResult = string.Empty;
        foreach (string itemId in allSelectedItemId)
        {
            finalResult = string.Concat(finalResult,"<br/>", GetItemName(itemId));
        }
        return finalResult.Remove(1,5);
    }

    protected string GetItemName(string id)
    {
        return this.database.GetItem(new ID(id)).Name;
    }
</script>

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>Edit Project</title>
    <script type="text/javascript" src="/sitecore/shell/customeditors/scripts/approver-search.js"></script>
    <script type="text/javascript" src="/sitecore/shell/customeditors/scripts/editor-search.js"></script>
</head>
<body>
    <%
        this.currentItemId = Request.QueryString["id"];
        this.databaseName = Request.QueryString["database"];
        this.database = Database.GetDatabase(this.databaseName);
        Item currentItem = database.GetItem(new ID(this.currentItemId));
    %>

    <div class="container" id="editProject">
        <form method="post" action="/api/sitecore/projects/update" name="projectform">
            
            <table cellpadding="10" cellspacing="10" style="width: 500px">
                <tr>
                    <td valign="top">Project Name: 
                    </td>
                    <td><% = GetFieldValue("Project Name", currentItem) %></td>
                </tr>
                <tr>
                    <td valign="top">Description:
                    </td>
                    <td><% = GetFieldValue("Description", currentItem) %></td>
                </tr>
                <tr>
                    <td valign="top">Archived:</td>
                    <td><% = GetFieldValue("Archived", currentItem) %></td>
                </tr>
                <tr>
                    <td valign="top">Approver:
                        <br />
                        <input type="text" id="txtApprover" value="<% = GetFieldValue("Approver", currentItem) %>" disabled style="width: 100%" />
                    </td>
                    <td valign="top">Search by User Name
                        <br />
                        <input type="text" id="txtSearchApprover" /><br />
                        <select id="lstApproverOptions" size="10" style="width: 100%">
                        </select>
                    </td>
                </tr>
                <tr>
                    <td valign="top">Editors:
                        <br />
                        <select id="lstEditorList" size="10" style="width: 100%">
                            <% var editors = GetFieldValue("Editors", currentItem);
                               var editorsArr = editors.Split('|');
                               foreach(var ed in editorsArr){
                            %>
                            <option value="<% = ed %>"><% = ed %></option>
                            <% } %>
                        </select>
                    </td>
                    <td>Search by User Name
                        <br />
                        <input type="text" id="txtSearchEditor" /><br />
                        <select id="lstEditorOptions" size="10" style="width: 100%">
                        </select>
                    </td>
                </tr>
                <tr>
                    <td>
                        <input type="button" onclick="Submit()" value="Save" />
                    </td>
                    <td>

                    </td>
                </tr>
                </table>
                <input type="hidden" id="projectId" name="projectId" value="<% = this.currentItemId %>" />
                <input type="hidden" id="projectApprover" name="projectApprover" value="<% = this.currentItemId %>" />
                <input type="hidden" id="projectEditors" name="projectEditors" value="<% = this.currentItemId %>" />
        </form>
    </div>
</html>

<script type="text/javascript">
    var approverSearch = new ApproverSearch();
    var editorSearch = new EditorSearch();

    function Submit() {
        vals = []
        var sel = document.getElementById("lstEditorList");
        for (var i = 0, n = sel.options.length; i < n; i++) { // looping over the options
            if (sel.options[i].value) vals.push(sel.options[i].value);
        }

        document.getElementById("projectApprover").value = document.getElementById("txtApprover").value;
        document.getElementById("projectEditors").value = vals.join("|");

        var http = new XMLHttpRequest();
        http.open("POST", "/api/sitecore/projects/update", true);
        http.setRequestHeader("Content-type","application/x-www-form-urlencoded");
        var params = "projectId=" + document.getElementById("projectId").value + "&projectApprover=" + document.getElementById("projectApprover").value + "&projectEditors=" + document.getElementById("projectEditors").value;
        http.send(params);
        http.onload = function() {
            location.reload();
        }
        
    }
</script>

The following two code blocks are the javascript files and facilitate the search.

var ApproverSearch = function () {
    this.ApproverText = document.getElementById("txtApprover");
    this.ApproverList = document.getElementById("lstApproverOptions");
    this.ApproverSearch = document.getElementById("txtSearchApprover");
    this.ApiEndpoint = "/api/sitecore/users/search";
    this.Init();
};

ApproverSearch.prototype.Init = function () {
    var _this = this;

    if (this.ApproverSearch) {
        this.ApproverSearch.addEventListener('input',
            function (eventArgs) {
                _this.InputBoxOnChange(eventArgs);
            });

        this.ApproverSearch.addEventListener('keydown',
            function (eventArgs) {
                _this.InputBoxOnKeydown(eventArgs);
            });
    }

    if (this.ApproverList) {
        this.ApproverList.addEventListener('dblclick',
            function (eventArgs) {
                _this.ApproverListDblClick(eventArgs);
            });
    }
};

ApproverSearch.prototype.InputBoxOnChange = function (eventArgs) {
    var v = this.ApproverSearch.value;
    this._fetchData(v);
};

ApproverSearch.prototype.UpdateList = function (data) {
    this.ApproverList.options.length = 0;

    for (var i = 0; i < data.length; i++) {
        this.ApproverList.options[i] = new Option(data[i]["fullname"], data[i]["username"]);
    }
};

ApproverSearch.prototype.InputBoxOnKeydown = function (e) {

    if (e.keyCode === 40 || e.keyCode === 9) { //down or tab
        // if tab reaches the end of list, use default behavior
        if (e.keyCode === 9 && this.CurrentFocus >= _listItems.length - 1) {
            return;
        }
        e.preventDefault();
        this.CurrentFocus++;
    } else if (e.keyCode === 38) { //up
        e.preventDefault();
        this.CurrentFocus--;
    } else if (e.keyCode === 13) {

        //If the ENTER key is pressed, prevent the form from being submitted
        e.preventDefault();
    }
};

ApproverSearch.prototype._fetchData = function (term) {
    var _this = this;
    var url = this.ApiEndpoint + "?term=" + term;

    fetch(url)
        .then(response => response.text())
        .then(data => {
            console.log(data);
            _this.UpdateList(JSON.parse(data));
        });
}


ApproverSearch.prototype.ApproverListDblClick = function (eventArgs) {
    var index = this.ApproverList.selectedIndex;
    this.ApproverText.value = this.ApproverList.options[index].value;
}
var EditorSearch = function () {
    this.EditorList = document.getElementById("lstEditorList");
    this.EditorOptions = document.getElementById("lstEditorOptions");
    this.EditorSearch = document.getElementById("txtSearchEditor");
    this.ApiEndpoint = "/api/sitecore/users/search";
    this.Init();
};

EditorSearch.prototype.Init = function () {
    var _this = this;

    if (this.EditorSearch) {
        this.EditorSearch.addEventListener('input',
            function (eventArgs) {
                _this.InputBoxOnChange(eventArgs);
            });

        this.EditorSearch.addEventListener('keydown',
            function (eventArgs) {
                _this.InputBoxOnKeydown(eventArgs);
            });
    }

    if (this.EditorList) {
        this.EditorList.addEventListener('dblclick',
            function (eventArgs) {
                _this.EditorListDblClick(eventArgs);
            });
    }

    if (this.EditorOptions) {
        this.EditorOptions.addEventListener('dblclick',
            function (eventArgs) {
                _this.EditorOptionsDblClick(eventArgs);
            });
    }
};

EditorSearch.prototype.InputBoxOnChange = function (eventArgs) {
    var v = this.EditorSearch.value;
    this._fetchData(v);
};

EditorSearch.prototype.UpdateList = function (data) {
    this.EditorOptions.options.length = 0;

    for (var i = 0; i < data.length; i++) {
        this.EditorOptions.options[i] = new Option(data[i]["fullname"], data[i]["username"]);
    }
};

EditorSearch.prototype.InputBoxOnKeydown = function (e) {

    if (e.keyCode === 40 || e.keyCode === 9) { //down or tab
        // if tab reaches the end of list, use default behavior
        if (e.keyCode === 9 && this.CurrentFocus >= _listItems.length - 1) {
            return;
        }
        e.preventDefault();
        this.CurrentFocus++;
    } else if (e.keyCode === 38) { //up
        e.preventDefault();
        this.CurrentFocus--;
    } else if (e.keyCode === 13) {

        //If the ENTER key is pressed, prevent the form from being submitted
        e.preventDefault();
    }
};

EditorSearch.prototype._fetchData = function (term) {
    var _this = this;
    var url = this.ApiEndpoint + "?term=" + term;

    fetch(url)
        .then(response => response.text())
        .then(data => {
            console.log(data);
            _this.UpdateList(JSON.parse(data));
        });
}


EditorSearch.prototype.EditorListDblClick = function (eventArgs) {
    var index = this.EditorList.selectedIndex;
    this.EditorList.remove(index);
}

EditorSearch.prototype.EditorOptionsDblClick = function (eventArgs) {
    var index = this.EditorOptions.selectedIndex;
    var value = this.EditorOptions.options[index].value;

    if (this._ensureUnique(value)) {
        var option = new Option(this.EditorOptions.options[index].text, value);
        var selectedLength = this.EditorList.options.length;
        this.EditorList.options[selectedLength] = option; 
    }
}

EditorSearch.prototype._ensureUnique = function (value) {
    for (var i = 0, n = this.EditorList.options.length; i < n; i++) { // looping over the options
        if (this.EditorList.options[i].value === value) {
            return false;
        }
    }
    return true;
}

Here is the controller that these 2 javascript controls use.

using Newtonsoft.Json;
using SampleSite.ServiceLayer.Workflow;
using SampleSite.Utilities;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;

namespace SampleSite.Controllers
{
    [Authorize]
    public class UsersController : Controller
    {
        Sitecore.Data.Database _database = null;
        UserService _userService = null;
        public UsersController()
        {
            _database = Sitecore.Configuration.Factory.GetDatabase("master");
            this._userService = new UserService();
        }

        //Path:  /api/sitecore/users/list
        public ActionResult List()
        {
            var userNames = this._userService.List();
            return new JsonNetResult(userNames, Formatting.Indented);
        }

        //Path:  /api/sitecore/users/search
        public ActionResult Search(string term)
        {
            var userNames = this._userService.Search(term);
            return new JsonNetResult(userNames, Formatting.Indented);
        }       
    }
}

The controller uses the UserService.

using SampleSite.Models.Workflow;
using Sitecore.Security.Accounts;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace SampleSite.ServiceLayer.Workflow
{
    public class UserService
    {
        Sitecore.Data.Database _database = null;
        public UserService()
        {
            _database = Sitecore.Configuration.Factory.GetDatabase("master");
        }

        //Path:  /api/sitecore/users/list
        public List<ProjectUser> List()
        {
            var userNames = UserManager.GetUsers().Select(x => new ProjectUser(x.Profile.UserName, x.Profile.FullName, x.DisplayName)).ToList();
            return userNames;
        }

        //Path:  /api/sitecore/users/search
        public List<ProjectUser> Search(string term)
        {
            var userNames = UserManager.GetUsers().Where(x => x.Profile.UserName.ToLower().Contains(term.ToLower())).Select(x => new ProjectUser(x.Profile.UserName, x.Profile.FullName, x.DisplayName)).ToList();
            return userNames;
        }
    }
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace SampleSite.Models.Workflow
{
    public class ProjectUser
    {
        public ProjectUser(string username, string displayname, string fullname)
        {
            this.Username = username;
            this.Displayname = displayname;
            this.Fullname = fullname;
        }
        public string Username {get;set;}
        public string Displayname { get; set; }
        public string Fullname { get; set; }
    }
}

There is a reporting feature that I haven’t discussed yet, however I will write another post describing that in details. In the near future, I will also be doing some refactoring of the code and release the entire project for download. Look for that in the new year.

About Phil Paris

Hi, my name is Phil Paris and I’m a Sitecore Architect and general Sitecore enthusiast. I’ve been working with Sitecore since 2013. Through this blog I will be sharing Sitecore insights, tips and tricks, best practices and general knowledge with the hopes to further the community at large. Please feel free to reach out to me at any time!

View all posts by Phil Paris →