Loading...
Area: Episerver CMS

Example: Implementing a custom admin view

(Thanks to Henrik Fransas at NetRelations for this example.)

Extending the user interface can be useful if you have a solution with non-Episerver content or you need extra admin views for complex issues that are difficult to solve with Episerver properties.

The code examples in this topic shows how to extend the editor in Episerver with an extra admin view:

This is a simple example on how you can create this view, and even if the example does not suit your needs, the same technique can be used for other scenarios.

The example takes you through the following steps:

Creating a database table

Step one in this example is to add a new table to the Episerver database called UserProfile. This table holds information such as first name, last name and phone number of the users in the database (the database created by the SQL membership provider). 

Create a table inside the existing database using this code:

SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

CREATE TABLE [dbo].[UserProfile](
	[UserId] [uniqueidentifier] NOT NULL,
	[FirstName] [nvarchar](200) NULL,
	[LastName] [nvarchar](200) NULL,
	[PhoneNumber] [varchar](50) NULL,
 CONSTRAINT [PK_UserProfile] PRIMARY KEY CLUSTERED 
(
	[UserId] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON) ON [PRIMARY]
) ON [PRIMARY]

GO

Creating a menu

To use MVC with Bootstrap and AngularJS and to keep the Episerver menu, the first thing you need to do is to create a class that implements the IMenuProvider and is decorated with the MenuProvider attribute. It looks like this:

using System.Collections.Generic;
using EPiServer.Security;
using EPiServer.Shell.Navigation;

namespace ExtendingEditUi.Business.Providers
{
    [MenuProvider]
    public class ProfileMenuProvider : IMenuProvider
    {
        public IEnumerable<MenuItem> GetMenuItems()
        {
            var toolbox = new SectionMenuItem("Profile-Admin", "/global/profileadmin")
            {
                IsAvailable = (request) => PrincipalInfo.HasEditorAccess
            };

            var profies = new UrlMenuItem("User profiles", "/global/profileadmin/profiles", "/profileadmin/profiles")
            {
                IsAvailable = (request) => PrincipalInfo.HasEditorAccess
            };

            return new MenuItem[] { toolbox, profies };
        }
    }
}

Create a SectionMenuItem which will be shown in the global menu (next to CMS, Find and so on). It is important to set the parameter IsAvailable on the menu item to the proper level so you do not expose the menu to people that should not be able to see it. In this example, it is visible to everyone who have edit access.

Then create a UrlMenuItem which is the submenu. Set the access level on this one as well. In this example, there is only one submenu, but it is possible to have different levels for different submenu items. This constructor has three parameters and the last one is the actual URL which is used when a user clicks on the menu item.

Implementing routing

When using Episerver, Episerver takes over all the routing for your website and by default, it is not possible to be routed to anything that is not created content. Since the extended view in this case is regular MVC, you must implement your own routing. You can read more about how Episerver implements routing here: http://world.episerver.com/documentation/developer-guides/CMS/routing/

Note: By implementing your own routing, you are skipping some of the build-in security in Episerver, and therefore it is very important to keep this in mind and implement the same security yourself.

Since Web API is used in this example, you need to set up routing for that as well.

You can do that in an initialization module or in global.asax. This is an example of how to do it in global.asax:

protected override void RegisterRoutes(RouteCollection routes)
  {
     base.RegisterRoutes(routes);

     routes.MapHttpRoute("webapi", "api/{controller}/{action}/{id}", new { id = RouteParameter.Optional });

     routes.MapRoute("ProfileAdmin", "profileadmin/{action}", new { controller = "ProfileAdmin", action = "index" });
     routes.MapRoute("ExistingPagesReport", "existingpagesreport/{action}", new { controller = "ExistingPagesReport", action = "Index" });
  }

In the example code, the incoming routes are implemented first and then a MapHttpRoute is added to be able to handle all Web API calls. The MapHttpRoute is called webapi and handles all calls to /api/*.

A MapRoute is added to handle the calls to the new custom view and it is set to handle all requests to profileadmin/* and sets the default controller to the ProfileAdmin controller we are going to create.

The last route is to handle requests to our custom report that is described Example: Creating a custom report.

Creating a controller

Since we are using MVC, you need a controller to handle the request to the custom view. Create an ordinary MVC controller like this:

using System.Web.Mvc;

namespace ExtendingEditUi.Controllers
{
    [Authorize(Roles = "Administrators, WebAdmins, WebEditors")]
    public class ProfileAdminController : Controller
    {
        // GET: ProfileAdmin
        public ActionResult Index()
        {
            return View();
        }

        public ActionResult Profiles()
        {
            return View();
        }
    }
}

Inside the controller, there are two actions and if you compare it to the Episerver controller, there is no currentPage as parameter for this one. The standard Index action is not used in this example but if you have a bigger implementation, you might use this as an index view to list all other available views.

Note: The class should be decorated with the Authorize attribute, otherwise it is publicly available for everybody. In this example, it is available for administrators and editors.

For this simple example, we are not using any logic in the controller, but you can do that and if you need to pass data to the view, create a viewmodel.

Creating a view

To make it easier to separate the logic, we have created a layout for this page even though it is only one page. You may have more than one page so it is a good practice to have a layout for your views.

The layout:

@using EPiServer.Framework.Web.Resources
@using EPiServer.Shell.Navigation

@{
    Layout = null;
}

<!DOCTYPE html>

<html ng-app="ProfilesApp">
<head>
    <title>@ViewBag.Title</title>

    <meta http-equiv="X-UA-Compatible" content="IE=Edge" />

    <!-- Shell -->
    @Html.Raw(ClientResources.RenderResources("ShellCore"))
    @Html.Raw(ClientResources.RenderResources("ShellWidgets"))

    <!-- LightTheme -->
    @Html.Raw(ClientResources.RenderResources("ShellCoreLightTheme"))

    <!-- Navigation -->
    @Html.Raw(ClientResources.RenderResources("Navigation"))

    <!-- Dojo Dashboard -->
    @Html.Raw(ClientResources.RenderResources("DojoDashboardCompatibility", new[] { ClientResourceType.Style }))

    <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.5/angular.min.js"></script>
    <script src="https://code.jquery.com/jquery-3.1.1.min.js" integrity="sha256-hVVnYaiADRTO2PzUGmuLJr8BLUSjGIZsDYGmIJLv2b8=" crossorigin="anonymous"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js" integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa" crossorigin="anonymous"></script>
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
    <script src="~/Static/js/ProfileAdmin/profiles.js"></script>
    <link href="~/Static/css/ProfileAdmin/profiles.css" rel="stylesheet" />
</head>
<body>
    @Html.Raw(Html.ShellInitializationScript())
    @Html.Raw(Html.GlobalMenu())
    <div class="container-fluid" ng-controller="profilesCtrl">
        @RenderBody()
    </div>
</body>
</html>

The menu is implemented using styles and CSS found in the cms.zip under the modules/_protected folder. The RenderResources and Html.Raw parts in the code example are mostly implementing Episerver parts, but the RenderResources is also used here as an initialization script to render the menu and other parts correctly.

After that, CDNs are used for the Bootstrap and AngularJS parts. This is not necessary and if you do not want to use CDNs, download the files instead and update the links.

If you need more information on AngularJs, see http://www.w3schools.com/angular/.

In this case, the Angular app is defined in the HTML head tag and the Angular controller in the wrapping div of the content. This works fine for simple cases, but if you have a lot of different controllers you may have define them elsewhere.

The view:

In the custom view, we have added a search field. When the user has done a search, a list of profiles is displayed. It is also possible to edit the profiles inside the list.

@{
    Layout = "Layout/ProfilesLayout.cshtml";
}

<div class="row">
    <div class="col-sm-12 col-md-12 main">
        <div id="ListView">
            <h1>Administrate user profiles</h1>
            <div class="row no-margin main-form">
                <form name="searchProfiles" id="searchProfiles">
                    <table class="table table-striped table-bordered">
                        <thead>
                            <tr>
                                <th>
                                    <label for="searchString">Name or Username</label>
                                </th>
                            </tr>
                        </thead>
                        <tbody>
                            <tr>
                                <td>
                                    <input type="text" ng-model="searchString" id="searchString" class="form-control">
                                </td>
                            </tr>
                        </tbody>
                        <tfoot>
                            <tr>
                                <td><input type="button" class="btn btn-default btn-md" value="Search" ng-click="search()" /></td>
                            </tr>
                        </tfoot>
                    </table>
                </form>
                <table class="table table-striped table-bordered">
                    <thead>
                        <tr>
                            <th class="col-md-1"></th>
                            <th class="col-md-2">UserName</th>
                            <th class="col-md-2">Email</th>
                            <th class="col-md-2">FirstName</th>
                            <th class="col-md-2">LastName</th>
                            <th class="col-md-3">Phone</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr class="pointer" ng-repeat-start="userProfile in userProfiles">
                            <td class="col-md-1"><button type="button" class="btn btn-default" ng-click="EditUserProfile(userProfile)"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></button></td>
                            <td class="col-md-2">{{userProfile.UserName}}</td>
                            <td class="col-md-2">{{userProfile.Email}}</td>
                            <td class="col-md-2">{{userProfile.FirstName}}</td>
                            <td class="col-md-2">{{userProfile.LastName}}</td>
                            <td class="col-md-2">{{userProfile.PhoneNumber}}</td>
                        </tr>
                        <tr ng-show="currentObject != null && editObject != null && currentObject.UserId == userProfile.UserId" ng-repeat-end="">
                            <td colspan="6">
                                <div class="panel panel-default">
                                    <div class="panel-body">
                                        <div class="main-form">
                                            <form name="editProfileForm" id="editProfileForm" role="form" ng-submit="submitEditUserProfile()">
                                                <div class="form-group">
                                                    <label>FirstName</label>
                                                    <input class="form-control" type="text" name="FirstName" id="FirstName" ng-model="currentObject.FirstName">
                                                </div>
                                                <div class="form-group">
                                                    <label>LastName</label>
                                                    <input class="form-control" type="text" name="LastName" id="LastName" ng-model="currentObject.LastName">
                                                </div>
                                                <div class="form-group">
                                                    <label>Phone</label>
                                                    <input class="form-control" type="text" name="PhoneNumber" id="PhoneNumber" ng-model="currentObject.PhoneNumber">
                                                </div>
                                                <button type="submit" class="btn btn-default" ng-disabled="editProfileForm.$invalid">Spara</button>
                                                <button type="button" class="btn btn-default" ng-click="CancelEditUserProfile()">Avbryt</button>
                                            </form>
                                        </div>
                                    </div>
                                </div>
                            </td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>
    </div>
</div>

To make it look like the row is expanding when a user edits a profile in the list, the angular repeat is started with ng-repeat-start instead of just ng-repeat, and another tr is added at ng-repeat-end. This adds an additional hidden row after each profile row which is filled up and displayed when a user clicks Edit.

Creating a JavaScript file with logic

To be able to connect the front with some logic, create a JavaScript file and declare the Angular app, controller, and logic:

var profilesApp = angular.module('ProfilesApp', []);

profilesApp.controller('profilesCtrl', function ($scope, $http) {

    $scope.userProfiles = null;
    $scope.currentObject = null;
    $scope.editObject = false;
    $scope.totalUserProfiles = 0;

    $scope.search = function () {
        var query = "";
        
        if ($scope.searchString) {
            query = $scope.searchString;
        }
        
        $scope.userProfiles = null;
        $http.get('/api/ProfileApi/SearchUserProfiles/?searchString=' + query)
			.then(function (result) {
			    $scope.totalUserProfiles = result.data.Count;
			    $scope.userProfiles = result.data;
			});
    };

    $scope.EditUserProfile = function (userProfile) {
        if ($scope.editObject === true) {
            $scope.currentObject = null;
            $scope.editObject = false;
        } else {
            $http.get('/api/ProfileApi/GetUserProfile?userId=' + userProfile.UserId)
                .success(function (data) {
                    $scope.currentObject = data;
                    $scope.editObject = true;
                });
        }
    };

    $scope.CancelEditUserProfile = function () {
        $scope.currentObject = null;
        $scope.editObject = false;
    };

    $scope.submitEditUserProfile = function () {
        if ($scope.currentObject != null && $scope.currentObject.UserId != null) {

            $http.post('/api/ProfileApi/UpsertUserProfile', $scope.currentObject)
                .success(function (data) {
                    $scope.currentObject = null;
                    $scope.editObject = false;
                    $scope.search($scope.searchString);
                })
                .error(function () {
                    alert("Error on update profile");
                });
        }
    };
});

There are four functions in this file; search, edit, cancelEdit, and submitEdit. All of them are using the data binding from the view through the scope variable.

To be able to communicate with the database, an ajax request is done to a Web API and the scope variable is updated with the returned data.

Creating a Web API

To create the needed REST API functions, we use Web API 2 where much of the functionality is built into the code from Microsoft:

using System;
using System.Web.Http;
using ExtendingEditUi.Business.Repositories;
using ExtendingEditUi.Models.Entities;

namespace ExtendingEditUi.Controllers.Api
{
    [Authorize(Roles = "Administrators, WebAdmins, WebEditors")]
    public class ProfileApiController : ApiController
    {
        private readonly IUserProfileRepository userProfileRepository;

        public ProfileApiController(IUserProfileRepository userProfileRepository)
        {
            this.userProfileRepository = userProfileRepository;
        }
        
        [AcceptVerbs("GET")]
        public IHttpActionResult SearchUserProfiles(string searchString)
        {
            return this.Ok(this.userProfileRepository.SearchUsers(searchString));
        }

        [AcceptVerbs("GET")]
        public IHttpActionResult GetUserProfile(string userId)
        {
            return this.Ok(this.userProfileRepository.GetUserProfile(userId));
        }

        [AcceptVerbs("POST")]
        public IHttpActionResult UpsertUserProfile(UserProfile userProfile)
        {
            try
            {
                this.userProfileRepository.UpsertUserProfile(userProfile);

            }
            catch (Exception ex)
            {
                return this.InternalServerError(ex);
            }

            return this.Ok("Update done");
        }

    }
}

We are using dependency injection inside the controller in the example and to make this work with Web API, you need to install the Microsoft.AspNet.WebApi package and enable dependency injection. 

http://world.episerver.com/blogs/Henrik-Fransas/Dates/2016/10/installing-webapi-and-enable-dependency-injection-for-it/

Other than that, it is a simple controller that sends the request through to the repository.

Note: Do not forget the Authorize attribute, otherwise you expose the controller to all the web. In this example, it is available for administrators and editors.

Creating a repository

There are many ways you can talk to the database. In this solution, we are using a micro ORM called Dapper (https://github.com/StackExchange/dapper-dot-net) that provides us with back-typed objects while we still have full control over the SQL. It also encourages us to use parametrized queries, which protects us against SQL Injections. Whichever solution you choose, remember to never write SQL code by concatenating strings since that makes your site open for SQL Injections.

using System.Collections.Generic;
using System.Data.SqlClient;
using System.Linq;
using Dapper;
using ExtendingEditUi.Models.Entities;

namespace ExtendingEditUi.Business.Repositories
{
    public class UserProfileRepository : IUserProfileRepository
    {
        private readonly IConfigRepository _configRepository;
        private readonly string _constringKey;

        public UserProfileRepository(IConfigRepository configRepository)
        {
            _configRepository = configRepository;
            _constringKey = "EPiServerDB";
        }

        public IEnumerable<UserProfile> SearchUsers(string searchString)
        {
            const string query = @"Select u.UserId, u.UserName, p.PropertyValueStrings as Email, up.FirstName, up.LastName, up.PhoneNumber
                                    From Users u 
	                                    Inner join Profiles p on (u.UserId = p.UserId AND p.PropertyNames like 'Email:%')
	                                    Left outer join UserProfile up on u.UserId = up.UserId
                                    Where UserName like @searchString OR (ISNULL(up.FirstName, '') + ' ' + ISNULL(up.LastName, '')) like @searchString";

            using (var conn = new SqlConnection(_configRepository.GetConnectionString(_constringKey)))
            {
                conn.Open();
                return conn.Query<UserProfile>(query, new { SearchString = string.Format("%{0}%", searchString)});
            }
        }

        public UserProfile GetUserProfile(string userId)
        {
            const string query = @"Select u.UserId, u.UserName, p.PropertyValueStrings as Email, up.FirstName, up.LastName, up.PhoneNumber
                                    From Users u 
	                                    Inner join Profiles p on (u.UserId = p.UserId AND p.PropertyNames like 'Email:%')
	                                    Left outer join UserProfile up on u.UserId = up.UserId
                                    Where u.UserId = @UserId";

            using (var conn = new SqlConnection(_configRepository.GetConnectionString(_constringKey)))
            {
                conn.Open();
                return conn.Query<UserProfile>(query, new { UserId = userId }).SingleOrDefault();
            }
        }

        public UserProfile UpsertUserProfile(UserProfile userProfile)
        {
            const string query = @"Update UserProfile
                                    Set FirstName = @FirstName, LastName = @LastName, PhoneNumber = @PhoneNumber
                                    Where UserId = @UserId

                                    If @@ROWCOUNT = 0
                                    BEGIN
	
	                                    Insert Into UserProfile
	                                    (UserId, FirstName, LastName, PhoneNumber)
	                                    Values
	                                    (@UserId, @FirstName, @LastName, @PhoneNumber)
                                    END

                                    Select u.UserId, u.UserName, p.PropertyValueStrings as Email, up.FirstName, up.LastName, up.PhoneNumber
                                    From Users u 
	                                    Inner join Profiles p on (u.UserId = p.UserId AND p.PropertyNames like 'Email:%')
	                                    Left outer join UserProfile up on u.UserId = up.UserId
                                    Where u.UserId = @UserId";

            using (var conn = new SqlConnection(_configRepository.GetConnectionString(_constringKey)))
            {
                conn.Open();
                return conn.Query<UserProfile>(query, new {userProfile.UserId, userProfile.FirstName, userProfile.LastName, userProfile.PhoneNumber }).SingleOrDefault();
            }
        }
    }
}

This is a straight forward repository, except for the Upsert function. This is a pattern to move the logic of determining if it should be an update or insert further down in the code. It works like this; first the code tries to do an update and if it gets zero rows affected back, there are no such rows in the database and it will do an insert instead. This does give us an extra execution for an insert but it does save us a lot of if-statements in the C# code.

After all this are done, you have a new menu option, Profile-Admin, and a view where you can search for and administer your user profiles. 

Last updated: Jul 11, 2019

Feedback?